mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -5,26 +5,26 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "../../config/assets/columns.config";
|
||||
import { buildToolbarActions } from "../../config/assets/toolbar.config";
|
||||
import { buildSelectionActions } from "../../config/assets/selection.config";
|
||||
import { buildRowActions } from "../../config/assets/rowActions.config";
|
||||
import { buildToolbarActions } from "../../config/assets/toolbar.config";
|
||||
import { buildSelectionActions } from "../../config/assets/selection.config";
|
||||
import { buildRowActions } from "../../config/assets/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function AssetsTable() {
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
const [archiveIds, setArchiveIds] = useState(null);
|
||||
const [archiveIds, setArchiveIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => {},
|
||||
setFilters: () => {},
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => { },
|
||||
setFilters: () => { },
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -39,38 +39,39 @@ export default function AssetsTable() {
|
||||
};
|
||||
|
||||
const exportConfig = {
|
||||
allData: assets,
|
||||
allData: assets,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_Assets`,
|
||||
filename: `${getTimestamp()}_Assets`,
|
||||
sheetName: "Assets",
|
||||
};
|
||||
|
||||
const resolveViewPath = (row) => {
|
||||
switch (row.file_type) {
|
||||
case "video": return `view/video/${row.asset_id}`;
|
||||
case "image": return `view/image/${row.asset_id}`;
|
||||
case "video": return `view/video/${row.asset_id}`;
|
||||
case "image": return `view/image/${row.asset_id}`;
|
||||
case "document": return `view/document/${row.asset_id}`;
|
||||
default: return `view/image/${row.asset_id}`;
|
||||
case "audio": return `view/audio/${row.asset_id}`;
|
||||
default: return `view/image/${row.asset_id}`;
|
||||
}
|
||||
};
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
onView: (row) => navigate(resolveViewPath(row)),
|
||||
onEdit: (row) => navigate(`edit/${row.asset_id}`),
|
||||
onView: (row) => navigate(resolveViewPath(row)),
|
||||
onEdit: (row) => navigate(`edit/${row.asset_id}`),
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchAssets, pagination, exportConfig, navigate,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onArchiveMany: (ids) => setArchiveIds(ids),
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onArchiveMany: (ids) => setArchiveIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { UserPlus, Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
const ROLE_COLORS = {
|
||||
admin: "bg-red-100 text-red-700 border-red-200",
|
||||
staff: "bg-green-100 text-green-700 border-green-200",
|
||||
};
|
||||
|
||||
function getFullName(u) {
|
||||
return u?.personal_info?.name?.full_name?.trim() || u?.email || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* added — current instructor list [{ user_id, display_name, ... }]
|
||||
* onAdd(inst) — called with { user_id, display_name } to add one instructor
|
||||
* onToggleLinked(user_id) — remove a linked user by their user_id
|
||||
*/
|
||||
export default function CourseInstructorPicker({ added = [], onAdd, onToggleLinked }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
const [externalName, setExternalName] = useState("");
|
||||
|
||||
const linkedIds = new Set(added.filter(i => i.user_id).map(i => String(i.user_id)));
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
if (users.length) return;
|
||||
setLoadingUsers(true);
|
||||
try {
|
||||
const { data } = await api.get("/admin/users", {
|
||||
params: { limit: 200 },
|
||||
});
|
||||
const rows = data?.data?.data ?? [];
|
||||
setUsers(rows.filter(u => u.acc_type === "staff" || u.acc_type === "admin"));
|
||||
} catch {
|
||||
// silently ignore — user can still add external
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
}, [users.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadUsers();
|
||||
}, [open, loadUsers]);
|
||||
|
||||
const toggleUser = (u) => {
|
||||
const uid = String(u.user_id);
|
||||
if (linkedIds.has(uid)) {
|
||||
onToggleLinked(u.user_id);
|
||||
} else {
|
||||
onAdd({ user_id: u.user_id, display_name: getFullName(u) });
|
||||
}
|
||||
};
|
||||
|
||||
const addExternal = () => {
|
||||
const name = externalName.trim();
|
||||
if (!name) return;
|
||||
onAdd({ user_id: null, display_name: name });
|
||||
setExternalName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild className="w-full">
|
||||
<Button type="button" variant="outline" size="sm" className="w-full justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
Add Instructor
|
||||
</span>
|
||||
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search by name or email…" />
|
||||
<CommandList>
|
||||
|
||||
{/* ── Staff / Admin accounts ── */}
|
||||
<CommandGroup heading="Staff & Admin Accounts">
|
||||
{loadingUsers && (
|
||||
<CommandItem disabled>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
|
||||
Loading users…
|
||||
</CommandItem>
|
||||
)}
|
||||
{!loadingUsers && users.length === 0 && (
|
||||
<CommandEmpty>No staff or admin accounts found.</CommandEmpty>
|
||||
)}
|
||||
{users.map((u) => {
|
||||
const uid = String(u.user_id);
|
||||
const checked = linkedIds.has(uid);
|
||||
const name = getFullName(u);
|
||||
return (
|
||||
<CommandItem
|
||||
key={uid}
|
||||
value={`${name} ${u.email}`}
|
||||
onSelect={() => toggleUser(u)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Checkbox checked={checked} className="pointer-events-none" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] shrink-0 ${ROLE_COLORS[u.acc_type] ?? ""}`}
|
||||
>
|
||||
{u.acc_type}
|
||||
</Badge>
|
||||
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
{/* ── External (no account) ── */}
|
||||
<CommandGroup heading="External (no account)">
|
||||
<div className="px-2 py-1.5 flex gap-2">
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={externalName}
|
||||
onChange={(e) => setExternalName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addExternal())}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 px-3 shrink-0"
|
||||
disabled={!externalName.trim()}
|
||||
onClick={addExternal}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw
|
||||
} from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Pagination, PaginationContent, PaginationItem,
|
||||
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
|
||||
} from '@/components/ui/pagination';
|
||||
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
if (!status) return <Badge variant="outline" className="text-muted-foreground text-xs">Not started</Badge>;
|
||||
return status === 'completed'
|
||||
? <Badge variant="outline" className="text-emerald-600 border-emerald-400 text-xs">Completed</Badge>
|
||||
: <Badge><RefreshCcw />In Progress</Badge>;
|
||||
}
|
||||
|
||||
function ProgressBar({ value, total, className = '' }) {
|
||||
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{value} / {total}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserAvatar({ name, email, avatarUrl }) {
|
||||
const initials = name
|
||||
? name.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()
|
||||
: (email?.[0] ?? '?').toUpperCase();
|
||||
return (
|
||||
<Avatar className="size-9 shrink-0">
|
||||
<AvatarImage src={avatarUrl ?? undefined} alt={name ?? email} />
|
||||
<AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dialog: full breakdown for one user ─────────────────────────────────────
|
||||
|
||||
function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
const { detailCache, detailLoading, fetchUserReadingProgress } = useAdminCourseReadingProgress();
|
||||
const breakdown = entry ? detailCache[entry.user_id] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && entry && !breakdown) {
|
||||
fetchUserReadingProgress(courseId, entry.user_id);
|
||||
}
|
||||
}, [open, entry]);
|
||||
|
||||
const lastSeen = entry?.last_accessed_at
|
||||
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg flex flex-col max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />}
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">
|
||||
{entry?.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="truncate">{entry?.user.email}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ── Meta strip ── */}
|
||||
{entry && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<StatusBadge status={entry.course_status} />
|
||||
<span className="text-xs">Last seen {lastSeen}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{entry.lessons_completed} / {entry.lessons_total} lessons
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Progress bar ── */}
|
||||
{entry && (
|
||||
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Unit / lesson breakdown — fills remaining height and scrolls ── */}
|
||||
<ScrollArea className="flex-1 min-h-0 pr-2">
|
||||
{detailLoading && !breakdown ? (
|
||||
<div className="space-y-3 py-1">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-36 ml-5" />
|
||||
<Skeleton className="h-3 w-40 ml-5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : breakdown?.length ? (
|
||||
<div className="space-y-4 py-1">
|
||||
{breakdown.map((unit, ui) => (
|
||||
<div key={unit.unit_id} className="space-y-2">
|
||||
{/* Unit header */}
|
||||
<div className="flex items-center gap-2">
|
||||
{unit.status === 'completed'
|
||||
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
|
||||
: unit.status === 'in_progress'
|
||||
? <Circle className="size-4 text-amber-400 shrink-0" />
|
||||
: <Circle className="size-4 text-muted-foreground/30 shrink-0" />
|
||||
}
|
||||
<span className="text-sm font-semibold truncate flex-1">
|
||||
Unit {ui + 1}: {unit.title}
|
||||
</span>
|
||||
{unit.status && <StatusBadge status={unit.status} />}
|
||||
</div>
|
||||
|
||||
{/* Lesson rows */}
|
||||
<div className="ml-6 space-y-1.5 border-l pl-3">
|
||||
{unit.lessons.map((lesson) => (
|
||||
<div key={lesson.lesson_id} className="flex items-center gap-2">
|
||||
{lesson.status === 'completed'
|
||||
? <CheckCircle2 className="size-3 text-emerald-500 shrink-0" />
|
||||
: lesson.status === 'in_progress'
|
||||
? <Circle className="size-3 text-amber-400 shrink-0" />
|
||||
: <Circle className="size-3 text-muted-foreground/25 shrink-0" />
|
||||
}
|
||||
<span className={`text-xs truncate flex-1 ${lesson.status ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||||
{lesson.title}
|
||||
</span>
|
||||
{lesson.status === 'completed' && lesson.completed_at && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
|
||||
{new Date(lesson.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No lesson data available.</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter showCloseButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── User summary card ────────────────────────────────────────────────────────
|
||||
|
||||
function UserCard({ entry, onOpen }) {
|
||||
const lastSeen = entry.last_accessed_at
|
||||
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(entry)}
|
||||
className="w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{entry.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
|
||||
</span>
|
||||
<StatusBadge status={entry.course_status} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{entry.user.email}</p>
|
||||
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
|
||||
<p className="text-xs">Last seen {lastSeen}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pagination controls ──────────────────────────────────────────────────────
|
||||
|
||||
function PaginationControls({ page, totalPages, onPage }) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages = [];
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
|
||||
// Show at most 5 page numbers around current
|
||||
const getVisible = () => {
|
||||
if (totalPages <= 5) return pages;
|
||||
if (page <= 3) return [1, 2, 3, 4, null, totalPages];
|
||||
if (page >= totalPages - 2) return [1, null, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
|
||||
return [1, null, page - 1, page, page + 1, null, totalPages];
|
||||
};
|
||||
|
||||
return (
|
||||
<Pagination className="mt-4">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); if (page > 1) onPage(page - 1); }}
|
||||
className={page === 1 ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getVisible().map((p, i) =>
|
||||
p === null ? (
|
||||
<PaginationItem key={`ellipsis-${i}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive={p === page}
|
||||
onClick={(e) => { e.preventDefault(); onPage(p); }}
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPage(page + 1); }}
|
||||
className={page === totalPages ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function CourseReadingProgressList({ courseId }) {
|
||||
const { progressList, listLoading, fetchCourseReadingProgress } = useAdminCourseReadingProgress();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [dialogEntry, setDialogEntry] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourseReadingProgress(courseId);
|
||||
}, [courseId]);
|
||||
|
||||
// Reset to page 1 when search changes
|
||||
useEffect(() => { setPage(1); }, [search]);
|
||||
|
||||
// ── Filter ────────────────────────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return progressList;
|
||||
return progressList.filter((e) =>
|
||||
e.user.full_name?.toLowerCase().includes(q) ||
|
||||
e.user.email?.toLowerCase().includes(q)
|
||||
);
|
||||
}, [progressList, search]);
|
||||
|
||||
// ── Paginate ──────────────────────────────────────────────────────────────
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const completedCount = progressList.filter((e) => e.course_status === 'completed').length;
|
||||
const inProgressCount = progressList.length - completedCount;
|
||||
|
||||
// ── Loading skeleton ──────────────────────────────────────────────────────
|
||||
if (listLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="border rounded-lg p-4 flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3.5 w-40" />
|
||||
<Skeleton className="h-3 w-56" />
|
||||
<Skeleton className="h-1.5 w-full rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-20 rounded-full shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────
|
||||
if (!progressList.length) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center gap-2">
|
||||
<BookOpen className="size-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No students have started reading this course yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Summary strip ── */}
|
||||
<div className="flex items-center gap-4 flex-wrap text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{progressList.length} enrolled
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3.5 text-emerald-500" />
|
||||
{completedCount} completed
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Circle className="size-3.5 text-amber-400" />
|
||||
{inProgressCount} in progress
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Search ── */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
|
||||
maxLength={50}
|
||||
className="pl-9 pr-16"
|
||||
/>
|
||||
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs tabular-nums pointer-events-none ${search.length >= 50 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{search.length}/50
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── List ── */}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No results for "{search}".</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{paginated.map((entry) => (
|
||||
<UserCard
|
||||
key={entry.user_id}
|
||||
entry={entry}
|
||||
onOpen={setDialogEntry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
<PaginationControls page={page} totalPages={totalPages} onPage={setPage} />
|
||||
|
||||
{/* ── Detail Dialog ── */}
|
||||
<UserDetailDialog
|
||||
open={!!dialogEntry}
|
||||
onOpenChange={(v) => { if (!v) setDialogEntry(null); }}
|
||||
entry={dialogEntry}
|
||||
courseId={courseId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export default function CoursesTable() {
|
||||
};
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onViewAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment/view`),
|
||||
onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`),
|
||||
onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`),
|
||||
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
import { Eye, ImageIcon, VideoIcon } from "lucide-react";
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/TextBlock";
|
||||
/* A similar strategy to BlockList.jsx so this will applies
|
||||
to Client side */
|
||||
|
||||
import { Eye, ImageIcon, VideoIcon, ZoomIn } from "lucide-react";
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||
import { VideoBlock } from "@/components/generic/Blocks/Client/VideoBlock";
|
||||
import { TextVideoBlock } from "@/components/generic/Blocks/Client/TextVideoBlock";
|
||||
import { TextImageBlock } from "@/components/generic/Blocks/Client/TextImageBlock";
|
||||
import { ImageBlock } from "@/components/generic/Blocks/Client/ImageBlock";
|
||||
import { TextBlock } from "@/components/generic/Blocks/Client/TextBlock";
|
||||
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
|
||||
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
|
||||
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
|
||||
|
||||
export function LessonHeader({ lesson }) {
|
||||
if (!lesson) return null;
|
||||
return (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="space-y-3 pb-2 sm:space-y-4">
|
||||
<div>
|
||||
<h1 style={{ fontSize: "1.875rem", fontWeight: 700, lineHeight: 1.2, margin: "0 0 0.4rem 0" }}>
|
||||
<h1 className="text-2xl font-bold leading-tight mb-1 sm:text-3xl sm:leading-[1.2]">
|
||||
{lesson.title}
|
||||
</h1>
|
||||
{lesson.description && (
|
||||
<p style={{ margin: "0.2rem 0", lineHeight: 1.75, textAlign: "justify" }}
|
||||
className="text-muted-foreground">
|
||||
<p className="mt-1 leading-relaxed text-sm text-muted-foreground sm:text-base sm:leading-[1.75] sm:text-justify">
|
||||
{lesson.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lesson.objectives?.length > 0 && (
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="rounded-lg border p-3 space-y-2 sm:p-4 sm:space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-md bg-green-100 flex items-center justify-center shrink-0">
|
||||
<div className="h-7 w-7 rounded-md bg-green-100 flex items-center justify-center shrink-0 sm:h-8 sm:w-8">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 text-green-600"
|
||||
className="h-3.5 w-3.5 text-green-600 sm:h-4 sm:w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -36,11 +47,11 @@ export function LessonHeader({ lesson }) {
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">Objective</p>
|
||||
<p className="text-xs font-semibold sm:text-sm">Objective</p>
|
||||
</div>
|
||||
<ul className="space-y-1.5 list-disc list-inside">
|
||||
<ul className="space-y-1 list-disc list-inside sm:space-y-1.5">
|
||||
{lesson.objectives.map((o) => (
|
||||
<li key={o.objective_id} className="text-sm text-muted-foreground">
|
||||
<li key={o.objective_id} className="text-xs text-muted-foreground sm:text-sm">
|
||||
{o.text}
|
||||
</li>
|
||||
))}
|
||||
@@ -51,6 +62,32 @@ export function LessonHeader({ lesson }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Zoomable image wrapper ───────────────────────────────────────────────────
|
||||
// Must be rendered inside a <PhotoProvider>. Shows a subtle zoom hint on hover.
|
||||
|
||||
export function ZoomableImage({ url, alt }) {
|
||||
if (!url) return null;
|
||||
return (
|
||||
<PhotoView src={url}>
|
||||
<div className="relative group cursor-zoom-in">
|
||||
<img
|
||||
src={url}
|
||||
alt={alt ?? ""}
|
||||
className="w-full rounded-md object-cover aspect-video"
|
||||
draggable={false}
|
||||
/>
|
||||
{/* Zoom hint badge — fades in on hover */}
|
||||
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
|
||||
<div className="flex items-center gap-1 bg-secondary text-sm px-2 py-1 rounded-full border">
|
||||
<ZoomIn className="size-4" />
|
||||
<span>Zoom</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PhotoView>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewImage({ url, alt }) {
|
||||
if (!url) {
|
||||
return (
|
||||
@@ -60,9 +97,7 @@ export function PreviewImage({ url, alt }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img src={url} alt={alt ?? ""} className="w-full rounded-md object-cover aspect-video" />
|
||||
);
|
||||
return <ZoomableImage url={url} alt={alt} />;
|
||||
}
|
||||
|
||||
export function PreviewVideo({ url, thumb }) {
|
||||
@@ -80,129 +115,107 @@ export function PreviewVideo({ url, thumb }) {
|
||||
<img src={thumb} alt="Video thumbnail" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<VideoIcon className="h-10 w-10 text-muted-foreground/40" />
|
||||
<VideoIcon className="h-8 w-8 text-muted-foreground/40 sm:h-10 sm:w-10" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="h-10 w-10 rounded-full bg-black/50 flex items-center justify-center">
|
||||
<VideoIcon className="h-5 w-5 text-white" />
|
||||
<div className="h-8 w-8 rounded-full bg-black/50 flex items-center justify-center sm:h-10 sm:w-10">
|
||||
<VideoIcon className="h-4 w-4 text-white sm:h-5 sm:w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
|
||||
<p className="text-white text-[10px] truncate">{url}</p>
|
||||
<p className="text-white text-[9px] truncate sm:text-[10px]">{url}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewBlock({ block }) {
|
||||
const { type, content } = block;
|
||||
const { id, type, content } = block;
|
||||
|
||||
if (type === "text") {
|
||||
if (!content.body) {
|
||||
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body }}
|
||||
/>
|
||||
);
|
||||
switch (type) {
|
||||
case "text":
|
||||
return <TextBlock blockId={id} content={content} readOnly />;
|
||||
case "image":
|
||||
return <ImageBlock content={content} readOnly />;
|
||||
case "text-image":
|
||||
return <TextImageBlock blockId={id} content={content} readOnly />;
|
||||
case "video":
|
||||
return <VideoBlock content={content} readOnly />;
|
||||
case "text-video":
|
||||
return <TextVideoBlock blockId={id} content={content} readOnly />;
|
||||
case "audio":
|
||||
return <AudioBlock content={content} />;
|
||||
case "code":
|
||||
return <CodeBlock content={content} />;
|
||||
case "markdown":
|
||||
return <MarkdownBlock content={content} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "image") {
|
||||
if (!content.url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-24 rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
No image selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<figure>
|
||||
<img src={content.url} alt={content.alt ?? ""} className="w-full rounded-md object-cover" />
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "text-image") {
|
||||
const imgLeft = content.image_position === "left";
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 items-start">
|
||||
{imgLeft && <PreviewImage url={content.url} alt={content.alt} />}
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
|
||||
/>
|
||||
{!imgLeft && <PreviewImage url={content.url} alt={content.alt} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "video") {
|
||||
if (!content.url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-24 rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<VideoIcon className="h-4 w-4" />
|
||||
No video selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <PreviewVideo url={content.url} thumb={content.thumbnail_url} />;
|
||||
}
|
||||
|
||||
if (type === "text-video") {
|
||||
const vidLeft = content.video_position === "left";
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 items-start">
|
||||
{vidLeft && <PreviewVideo url={content.url} thumb={content.thumbnail_url} />}
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
|
||||
/>
|
||||
{!vidLeft && <PreviewVideo url={content.url} thumb={content.thumbnail_url} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── PreviewContent ───────────────────────────────────────────────────────────
|
||||
// PhotoProvider wraps ALL blocks so images across the whole lesson share
|
||||
// one lightbox session — users can swipe between them naturally.
|
||||
|
||||
export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
|
||||
return (
|
||||
<>
|
||||
<PhotoProvider
|
||||
speed={() => 300}
|
||||
easing={(type) => (type === 2 ? "cubic-bezier(0.36, 0, 0.66, -0.56)" : "cubic-bezier(0.34, 1.56, 0.64, 1)")}
|
||||
toolbarRender={({ onScale, scale, rotate, onRotate }) => (
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<button
|
||||
onClick={() => onScale(scale + 0.5)}
|
||||
className="text-white/80 hover:text-white transition-colors"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<LessonHeader lesson={lesson} />
|
||||
{blocks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<Eye className="h-8 w-8 opacity-20" />
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-sm text-muted-foreground sm:py-16">
|
||||
<Eye className="h-6 w-6 opacity-20 sm:h-8 sm:w-8" />
|
||||
<p>{empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
blocks.map((block) => (
|
||||
<div key={block.id}>
|
||||
<PreviewBlock block={block} />
|
||||
</div>
|
||||
))
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
{blocks.map((block) => (
|
||||
<div key={block.id}>
|
||||
<PreviewBlock block={block} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</PhotoProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewChrome({ title, children }) {
|
||||
export function PreviewChrome({ title, children, showChrome = true }) {
|
||||
if (!showChrome) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 bg-muted/60 border-b">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-400" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-green-400" />
|
||||
<div className="flex-1 mx-3 h-5 rounded bg-background/60 border text-[10px] flex items-center px-2 text-muted-foreground/60 truncate">
|
||||
<span className="hidden h-2.5 w-2.5 rounded-full bg-red-400 sm:inline-block" />
|
||||
<span className="hidden h-2.5 w-2.5 rounded-full bg-yellow-400 sm:inline-block" />
|
||||
<span className="hidden h-2.5 w-2.5 rounded-full bg-green-400 sm:inline-block" />
|
||||
<div className="flex-1 sm:mx-3 h-5 rounded bg-background/60 border text-[10px]
|
||||
flex items-center px-2 text-muted-foreground/60 truncate">
|
||||
{title ?? "Lesson Preview"}
|
||||
</div>
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground/60 shrink-0" />
|
||||
</div>
|
||||
<div className="p-3 sm:p-5">
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export default function UnitsTable({ courseId }) {
|
||||
);
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onViewQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz/view`),
|
||||
onQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz`),
|
||||
onViewLessons: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/lessons`),
|
||||
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo, useRef, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { buildDataColumns, columnPinning } from "../../config/tiers/payments/columns.config";
|
||||
import { buildToolbarActions } from "../../config/tiers/payments/toolbar.config";
|
||||
import { buildSelectionActions } from "../../config/tiers/payments/selection.config";
|
||||
import { buildRowActions } from "../../config/tiers/payments/rowActions.config";
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function PaymentsTable({ planId = null }) {
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => { },
|
||||
setFilters: () => { },
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
payments, paymentAttributes,
|
||||
paymentPagination, setPaymentPagination,
|
||||
loading, fetchPayments,
|
||||
} = useTiers();
|
||||
|
||||
const exportConfig = {
|
||||
allData: payments,
|
||||
attributes: paymentAttributes,
|
||||
filename: `${getTimestamp()}_Payments`,
|
||||
sheetName: "Payments",
|
||||
};
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onView: (row) => navigate(`/admin/tiers/payments/${row.payment_id}/view`),
|
||||
}), []);
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchPayments,
|
||||
pagination: paymentPagination,
|
||||
exportConfig,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(paymentAttributes, rowActions),
|
||||
[paymentAttributes]
|
||||
);
|
||||
|
||||
// Pass planId as a locked filter to DataTable's onFetch
|
||||
const handleFetch = useCallback((params = {}) => {
|
||||
const baseFilters = planId
|
||||
? [{ field: "plan_id", value: planId }, ...(params.filters ?? [])]
|
||||
: (params.filters ?? []);
|
||||
fetchPayments({ ...params, filters: baseFilters });
|
||||
}, [planId, fetchPayments]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
title="Payments"
|
||||
data={payments}
|
||||
columns={columns}
|
||||
attributes={paymentAttributes}
|
||||
pagination={paymentPagination}
|
||||
setPagination={setPaymentPagination}
|
||||
loading={loading}
|
||||
onFetch={handleFetch}
|
||||
onFetchFilterData={async () => []}
|
||||
onRefsReady={(refs) => { tableRefsRef.current = refs; }}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="payment"
|
||||
emptyMessage="No payments found."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useMemo, useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "../../config/tiers/plans/columns.config";
|
||||
import { buildToolbarActions } from "../../config/tiers/plans/toolbar.config";
|
||||
import { buildSelectionActions } from "../../config/tiers/plans/selection.config";
|
||||
import { buildRowActions } from "../../config/tiers/plans/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function TierPlansTable() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
plans, planAttributes, planPagination, setPlanPagination,
|
||||
loading, fetchPlans, deletePlan, restorePlan,
|
||||
bulkDeletePlans, bulkRestorePlans,
|
||||
} = useTiers();
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
const [archiveIds, setArchiveIds] = useState(null);
|
||||
const [restoreIds, setRestoreIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => {},
|
||||
tableInstance: null,
|
||||
});
|
||||
|
||||
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
|
||||
|
||||
const handleToggleArchived = useCallback(() => {
|
||||
const next = !showArchived;
|
||||
setShowArchived(next);
|
||||
fetchPlans({
|
||||
page: 1,
|
||||
limit: planPagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
archived: next,
|
||||
});
|
||||
}, [showArchived, planPagination, fetchPlans]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
setArchiveTarget(null);
|
||||
setRestoreTarget(null);
|
||||
setArchiveIds(null);
|
||||
setRestoreIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchPlans({
|
||||
page: 1,
|
||||
limit: planPagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
archived: showArchived,
|
||||
});
|
||||
};
|
||||
|
||||
const exportConfig = useMemo(() => ({
|
||||
allData: plans,
|
||||
attributes: planAttributes,
|
||||
filename: `${getTimestamp()}_TierPlans`,
|
||||
sheetName: "Tier Plans",
|
||||
}), [plans, planAttributes]);
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
navigate,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
showArchived,
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchPlans,
|
||||
pagination: planPagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
showArchived,
|
||||
onToggleArchived: handleToggleArchived,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onArchiveMany: (ids) => setArchiveIds(ids),
|
||||
onRestoreMany: (ids) => setRestoreIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(planAttributes, rowActions),
|
||||
[planAttributes, rowActions]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="Tier Plans"
|
||||
data={plans}
|
||||
columns={columns}
|
||||
attributes={planAttributes}
|
||||
pagination={planPagination}
|
||||
setPagination={setPlanPagination}
|
||||
loading={loading}
|
||||
onFetch={fetchPlans}
|
||||
onFetchFilterData={async () => []}
|
||||
onRefsReady={handleRefsReady}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="plan"
|
||||
emptyMessage="No tier plans found."
|
||||
/>
|
||||
|
||||
{/* Single archive */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||
entity={archiveTarget}
|
||||
entityLabel="Plan"
|
||||
getName={(r) => r?.label}
|
||||
onArchive={(entity) => deletePlan(entity?.plan_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Single restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||
entity={restoreTarget}
|
||||
entityLabel="Plan"
|
||||
getName={(r) => r?.label}
|
||||
onRestore={(entity) => restorePlan(entity?.plan_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk archive */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveIds}
|
||||
onOpenChange={(v) => !v && setArchiveIds(null)}
|
||||
ids={archiveIds ?? []}
|
||||
entityLabel="Plan"
|
||||
onArchive={({ ids }) => bulkDeletePlans(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreIds}
|
||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||
ids={restoreIds ?? []}
|
||||
entityLabel="Plan"
|
||||
onRestore={({ ids }) => bulkRestorePlans(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -129,9 +130,9 @@ export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) {
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Creating..." : "Add group"}
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -124,9 +125,9 @@ export function EditGroupDialog({ open, onOpenChange, group, onSubmit, loading }
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Eye, Pencil, Archive, ShelvingUnit, NotebookPen } from "lucide-react";
|
||||
import { Eye, Pencil, Archive, ShelvingUnit, NotebookPen, ClipboardList } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment }) {
|
||||
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
@@ -22,6 +22,14 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
|
||||
onClick: (row) => onViewUnits(row),
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
key: "view_assessment",
|
||||
label: "View Assessment",
|
||||
icon: <ClipboardList className="h-3.5 w-3.5" />,
|
||||
className: "text-purple-700 hover:text-purple-600",
|
||||
onClick: (row) => onViewAssessment(row),
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "modify_assessment",
|
||||
label: "Modify Assessment",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { Plus, RefreshCw, Download, Archive, ArrowUpAZIcon } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
@@ -43,6 +43,14 @@ export function buildToolbarActions({
|
||||
variant: "default",
|
||||
onClick: () => navigate("/admin/courses/add"),
|
||||
},
|
||||
{
|
||||
key: "categories",
|
||||
type: "button",
|
||||
label: "Categories",
|
||||
icon: <ArrowUpAZIcon className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
onClick: () => navigate("/admin/courses/categories"),
|
||||
},
|
||||
{
|
||||
key: "archived-courses",
|
||||
type: "button",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Eye, Pencil, Archive, BookCheck, NotebookPen } from "lucide-react";
|
||||
import { Eye, Pencil, Archive, BookCheck, NotebookPen, ClipboardList } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz }) {
|
||||
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz, onViewQuiz }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
@@ -22,13 +22,21 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
|
||||
onClick: (row) => onViewLessons(row),
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
key: "view_quiz",
|
||||
label: "View Quiz",
|
||||
icon: <ClipboardList className="h-3.5 w-3.5" />,
|
||||
className: "text-purple-700 hover:text-purple-600",
|
||||
onClick: (row) => onViewQuiz(row),
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "modify_quiz",
|
||||
label: "Modify Quiz",
|
||||
icon: <NotebookPen className="h-3.5 w-3.5" />,
|
||||
className: "text-purple-700 hover:text-purple-600",
|
||||
onClick: (row) => onQuiz(row),
|
||||
separator: true
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Eye, Pencil, Archive, ArchiveRestore, Info } from "lucide-react";
|
||||
import { Eye, Pencil, Archive, ArchiveRestore, Info, NotebookPen } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
||||
return [
|
||||
@@ -14,6 +14,14 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
|
||||
icon: <Pencil className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.task_id}/edit`),
|
||||
},
|
||||
{
|
||||
key: "completions",
|
||||
label: "Completions",
|
||||
icon: <NotebookPen className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.task_id}/completions`),
|
||||
separator: true,
|
||||
className: "text-sky-600",
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// config/task_completion/columns.config.jsx
|
||||
// Column definitions and pinning config for the TaskCompletions table.
|
||||
import { format } from "date-fns";
|
||||
import { buildColumns, longTextCell } from "@/utils/table.util";
|
||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
// ─── Shared 12-hour timestamp formatter ───────────────────────────────────────
|
||||
const formatTimestamp = (value) => {
|
||||
if (!value) return <span className="text-muted-foreground/40">-</span>;
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(value), "MMM d, yyyy · h:mm a")}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||
const cellOverrides = {
|
||||
completion_id: longTextCell("Completion ID"),
|
||||
task_id: longTextCell("Task ID"),
|
||||
note: longTextCell("Note"),
|
||||
submitted_at: (info) => formatTimestamp(info.getValue()),
|
||||
createdAt: (info) => formatTimestamp(info.getValue()),
|
||||
updatedAt: (info) => formatTimestamp(info.getValue()),
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the TaskCompletions table.
|
||||
*
|
||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||
* @param {Array} rowActions Row-level kebab action definitions
|
||||
* @returns {Array} TanStack column definitions
|
||||
*/
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Completion Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// config/task_completion/rowActions.config.jsx
|
||||
import { Eye, Archive, RotateCcw } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View",
|
||||
icon: <Eye className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.completion_id}/view`),
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
className: "text-destructive focus:text-destructive",
|
||||
icon: <Archive className="size-4" />,
|
||||
onClick: (row) => onArchive(row),
|
||||
hidden: () => showArchived,
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
label: "Restore",
|
||||
className: "text-green-600 focus:text-green-600",
|
||||
icon: <RotateCcw className="size-4" />,
|
||||
onClick: (row) => onRestore(row),
|
||||
hidden: () => !showArchived,
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// config/task_completion/selection.config.jsx
|
||||
import { Download, Archive, RotateCcw } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildSelectionActions({
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onBulkArchive,
|
||||
onBulkRestore,
|
||||
getTableInstance,
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
key: "export-selected",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
selectedRows: rows,
|
||||
tableInstance: table ?? getTableInstance?.(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: showArchived ? "restore-selected" : "archive-selected",
|
||||
label: showArchived ? "Restore" : "Archive",
|
||||
icon: showArchived ? (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
),
|
||||
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
||||
onClick: (rows) => {
|
||||
const ids = rows.map((r) => r.completion_id).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
showArchived ? onBulkRestore?.(ids) : onBulkArchive?.(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// config/task_completion/toolbar.config.jsx
|
||||
import { RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchCompletions,
|
||||
taskListId,
|
||||
taskId,
|
||||
pagination,
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onToggleArchived,
|
||||
getFilters,
|
||||
getSort,
|
||||
getTableInstance,
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
key: "refresh",
|
||||
type: "button",
|
||||
icon: <RefreshCw className="size-4" />,
|
||||
label: "Refresh",
|
||||
onClick: () => {
|
||||
fetchCompletions(taskListId, taskId, {
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters?.() ?? [],
|
||||
sort: getSort?.() ?? [],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
icon: <Download className="size-4" />,
|
||||
label: "Export",
|
||||
onClick: (table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: table ?? getTableInstance?.(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "toggle-archived",
|
||||
type: "button",
|
||||
icon: <Archive className="size-4" />,
|
||||
label: showArchived ? "Active" : "Archived",
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: onToggleArchived,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buildColumns } from "@/utils/table.util";
|
||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
const STATUS_BADGE = {
|
||||
pending: "secondary",
|
||||
completed: "default",
|
||||
failed: "destructive",
|
||||
cancelled: "outline",
|
||||
expired: "outline",
|
||||
refunded: "outline",
|
||||
};
|
||||
|
||||
const TIER_BADGE = { premium: "default", exclusive: "destructive" };
|
||||
|
||||
const cellOverrides = {
|
||||
status: (info) => (
|
||||
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
|
||||
{info.getValue()}
|
||||
</Badge>
|
||||
),
|
||||
amount: (info) => {
|
||||
const row = info.row.original;
|
||||
return (
|
||||
<span className="text-sm font-medium">
|
||||
{row.currency} {Number(info.getValue()).toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
"plan.tier": (info) => (
|
||||
<Badge variant={TIER_BADGE[info.getValue()] ?? "outline"} className="capitalize">
|
||||
{info.getValue()}
|
||||
</Badge>
|
||||
),
|
||||
paid_at: (info) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{info.getValue() ? new Date(info.getValue()).toLocaleString() : "—"}
|
||||
</span>
|
||||
),
|
||||
"user.email": (info) => (
|
||||
<span className="text-sm">{info.getValue() ?? "—"}</span>
|
||||
),
|
||||
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Payment Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Eye } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onView }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View Payment",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onView(row),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildSelectionActions({ exportConfig, getTableInstance }) {
|
||||
return [
|
||||
{
|
||||
key: "export-selected",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
selectedRows: rows,
|
||||
tableInstance: table ?? getTableInstance(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RefreshCw, Download } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchPayments,
|
||||
pagination,
|
||||
exportConfig,
|
||||
getFilters,
|
||||
getSort,
|
||||
getTableInstance,
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
key: "refresh",
|
||||
type: "button",
|
||||
label: "Refresh",
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => fetchPayments({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters(),
|
||||
sort: getSort(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: getTableInstance(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// config/columns.config.jsx
|
||||
// Column definitions and pinning config for the Users table.
|
||||
|
||||
import { buildColumns } from "@/utils/table.util";
|
||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Book, BookOpenCheck, Clock } from "lucide-react";
|
||||
import { formatDuration } from "@/utils/timestamp.util";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
|
||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||
const cellOverrides = {
|
||||
unitCount: (info) => {
|
||||
const count = parseInt(info.getValue() ?? 0, 10);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Book className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{count} {count === 1 ? "unit" : "units"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
lessonCount: (info) => {
|
||||
const count = parseInt(info.getValue() ?? 0, 10);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookOpenCheck className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{count} {count === 1 ? "lesson" : "lessons"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
duration_seconds: (info) => {
|
||||
const seconds = parseInt(info.getValue() ?? 0, 10);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{formatDuration(seconds)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the Users table.
|
||||
*
|
||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||
* @param {Array} rowActions Row-level kebab action definitions
|
||||
* @returns {Array} TanStack column definitions
|
||||
*/
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Course Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Eye, Pencil, Archive, RotateCcw, ShelvingUnit } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View Plan",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/view`),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit Plan",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/edit`),
|
||||
hidden: () => showArchived,
|
||||
},
|
||||
{
|
||||
key: "view_units",
|
||||
label: "View Payments",
|
||||
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => navigate(`/admin/tiers/payments?plan_id=${row.plan_id}`),
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onArchive(row),
|
||||
separator: true,
|
||||
hidden: () => showArchived,
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
label: "Restore",
|
||||
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||
className: "text-emerald-600",
|
||||
onClick: (row) => onRestore(row),
|
||||
hidden: () => !showArchived,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Download, Archive, RotateCcw } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildSelectionActions({
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onArchive,
|
||||
onArchiveMany,
|
||||
onRestoreMany,
|
||||
getTableInstance,
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
key: "export-selected",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
selectedRows: rows,
|
||||
tableInstance: table ?? getTableInstance(),
|
||||
}),
|
||||
},
|
||||
!showArchived && {
|
||||
key: "archive-selected",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
||||
onClick: (rows) => {
|
||||
const ids = rows.map((r) => r.plan_id);
|
||||
ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids);
|
||||
},
|
||||
},
|
||||
showArchived && {
|
||||
key: "restore-selected",
|
||||
label: "Restore",
|
||||
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||
className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-600",
|
||||
onClick: (rows) => {
|
||||
const ids = rows.map((r) => r.plan_id);
|
||||
onRestoreMany(ids);
|
||||
},
|
||||
},
|
||||
].filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchPlans,
|
||||
pagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
showArchived,
|
||||
onToggleArchived,
|
||||
getFilters,
|
||||
getSort,
|
||||
getTableInstance,
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
key: "refresh",
|
||||
type: "button",
|
||||
label: "Refresh",
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => fetchPlans({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters(),
|
||||
sort: getSort(),
|
||||
archived: showArchived,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: getTableInstance(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "create",
|
||||
type: "button",
|
||||
label: "New Plan",
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
hidden: showArchived,
|
||||
onClick: () => navigate("/admin/tiers/plans/add"),
|
||||
},
|
||||
{
|
||||
key: "toggle-archived",
|
||||
type: "button",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
label: showArchived ? "Active Plans" : "Archived Plans",
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: onToggleArchived,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -4,12 +4,26 @@
|
||||
import { buildColumns } from "@/utils/table.util";
|
||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
import { OverflowBadges } from "@/components/generic/OverflowBadges";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
const cellOverrides = {
|
||||
groups: (info) => (
|
||||
<OverflowBadges
|
||||
items={info.getValue() ?? []}
|
||||
keyKey="group_id"
|
||||
labelKey="group_code"
|
||||
dialogTitleKey="name"
|
||||
dialogTitle="All groups"
|
||||
badgeClassName="text-xs font-mono"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the Users table.
|
||||
*
|
||||
@@ -22,7 +36,7 @@ export function buildUserColumns(attributes, rowActions) {
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
|
||||
import { RefreshCw, Download, UserPlus, Archive } from "lucide-react";
|
||||
import { RefreshCw, Download, UserPlus, Archive, Activity } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
/**
|
||||
@@ -34,6 +34,15 @@ export function buildToolbarActions({ fetchUsers, pagination, exportConfig, navi
|
||||
className: "text-primary-foreground",
|
||||
onClick: () => navigate("add/staff"),
|
||||
},
|
||||
{
|
||||
key: "activities",
|
||||
type: "button",
|
||||
icon: <Activity className="h-3.5 w-3.5" />,
|
||||
label: "Activities",
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: () => navigate("/admin/activity"),
|
||||
},
|
||||
{
|
||||
key: "archived-users",
|
||||
type: "button",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* Oct. 10, 2025 lash0000 001 Initial creation - STAR Phase 1 Project
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { Outlet, useNavigate } from "react-router-dom"
|
||||
import { useRef, useEffect } from "react"
|
||||
import { useAuth } from "@/contexts/AuthContext"
|
||||
import { AdminProvider } from "@/contexts/provider/AdminProvider" // ← add
|
||||
|
||||
@@ -20,74 +21,75 @@ import { Toaster } from "sonner"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import UserMenu from "@/components/generic/UserMenu"
|
||||
import NotificationBell from "@/components/generic/NotificationBell"
|
||||
import { ROLE_CONFIG } from "@/data/profile.data"
|
||||
|
||||
const AdminLayout = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const headerRef = useRef(null)
|
||||
|
||||
const role = ROLE_CONFIG[user?.acc_type] ?? { label: user?.acc_type, variant: 'outline' }
|
||||
|
||||
async function handleLogout() {
|
||||
// setSignOutOpen(true);
|
||||
// setSignOutLoading(true);
|
||||
|
||||
// try {
|
||||
// await logout();
|
||||
// navigate("/", { replace: true });
|
||||
// } finally {
|
||||
// setSignOutLoading(false);
|
||||
// setSignOutOpen(false);
|
||||
// }
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!headerRef.current) return
|
||||
const update = () => {
|
||||
document.documentElement.style.setProperty('--navbar-h', `${headerRef.current.offsetHeight}px`)
|
||||
}
|
||||
update()
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(headerRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section id="philproperties-admin" className="min-h-screen flex flex-col">
|
||||
<TooltipProvider>
|
||||
<div className={cn('sticky top-0 z-50 bg-background')} >
|
||||
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin`)}>
|
||||
<img src="/philpro-white.png" alt="" className="object-cover" />
|
||||
{/* AdminProvider wraps header + body so UserMenu can access ProfileProvider */}
|
||||
<AdminProvider>
|
||||
<div ref={headerRef} className={cn('fixed top-0 z-50 w-full bg-background border-b')} >
|
||||
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin`)}>
|
||||
<img src="/philpro-white.png" alt="" className="object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height="16"
|
||||
width="16"
|
||||
viewBox="0 0 16 16"
|
||||
strokeLinejoin="round"
|
||||
className="xs:hidden sm:block fill-slate-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
|
||||
<Badge variant={role.variant} className="xs:hidden md:block capitalize">
|
||||
{role.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height="16"
|
||||
width="16"
|
||||
viewBox="0 0 16 16"
|
||||
strokeLinejoin="round"
|
||||
className="xs:hidden sm:block fill-slate-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationBell />
|
||||
<UserMenu />
|
||||
</div>
|
||||
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
|
||||
<Badge variant={role.variant} className="xs:hidden md:block capitalize">
|
||||
{role.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── All admin contexts live here, scoped to admin routes only ── */}
|
||||
<AdminProvider>
|
||||
<div id="main-body" className="bg-slate-100 flex-1 flex flex-col">
|
||||
<div id="main-body" className="bg-slate-100 flex-1 flex flex-col" style={{ paddingTop: 'var(--navbar-h)' }}>
|
||||
<Outlet />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</div>
|
||||
</AdminProvider>
|
||||
|
||||
{/* Footer sits outside AdminProvider, at the bottom of the flex column */}
|
||||
{/* Footer sits outside AdminProvider intentionally */}
|
||||
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
|
||||
© Philproperties, 2026
|
||||
</footer>
|
||||
|
||||
@@ -12,8 +12,8 @@ function scrollTo(sectionId) {
|
||||
export default function AdminDashboard() {
|
||||
return (
|
||||
<div>
|
||||
{/* ── Sticky tab bar — driven by the same array ── */}
|
||||
<div className="sticky z-10 bg-background border-b" style={{ top: 'var(--navbar-h)' }}>
|
||||
{/* ── Fixed tab bar — driven by the same array ── */}
|
||||
<div className="fixed z-10 w-full bg-background border-b" style={{ top: 'var(--navbar-h)' }}>
|
||||
<Tabs defaultValue="">
|
||||
<ScrollArea className="max-w-full overflow-x-auto w-full">
|
||||
<TabsList className="bg-background rounded-none justify-start mx-2 my-1 flex gap-1">
|
||||
@@ -37,6 +37,7 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
|
||||
{/* ── Sections — same array, one DashboardGrid per entry ── */}
|
||||
<div style={{ paddingTop: '40px' }}>
|
||||
{ADMIN_SECTIONS.map((s) => (
|
||||
<div key={s.id} id={s.id} style={{ scrollMarginTop: 'calc(var(--navbar-h) + 40px)' }} className="min-h-64">
|
||||
{s.tiles.length > 0 ? (
|
||||
@@ -49,6 +50,7 @@ export default function AdminDashboard() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useUsers } from "@/contexts/AdminUserContext";
|
||||
import { House, RefreshCw, ChevronLeft, ChevronRight, ExternalLink, CalendarIcon } from "lucide-react";
|
||||
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
|
||||
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
||||
import { timeAgo } from "@/utils/timestamp.util";
|
||||
|
||||
const BREADCRUMB = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Activity Feed" },
|
||||
];
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
function toDateStr(d) {
|
||||
if (!d) return undefined;
|
||||
return d.toLocaleDateString("en-CA"); // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export default function ActivityFeed() {
|
||||
const navigate = useNavigate();
|
||||
const { fetchActivity, activity, activityPagination, loading } = useUsers();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [action, setAction] = useState("all");
|
||||
const [from, setFrom] = useState(null);
|
||||
const [to, setTo] = useState(null);
|
||||
|
||||
const load = useCallback(
|
||||
(p = 1) => {
|
||||
fetchActivity({
|
||||
page: p,
|
||||
limit: LIMIT,
|
||||
action: action === "all" ? undefined : action,
|
||||
from: toDateStr(from),
|
||||
to: toDateStr(to),
|
||||
});
|
||||
setPage(p);
|
||||
},
|
||||
[fetchActivity, action, from, to]
|
||||
);
|
||||
|
||||
useEffect(() => { load(1); }, [action, from, to]);
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 pb-10">
|
||||
|
||||
{/* ─── Header ────────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={BREADCRUMB} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Activity Feed</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All user actions across the system — {activityPagination.totalRecords} total
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => load(page)} disabled={loading}>
|
||||
<RefreshCw className={`size-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ─── Filters ───────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap gap-3 bg-card border rounded-lg p-4">
|
||||
<div className="flex flex-col gap-1 min-w-[180px]">
|
||||
<span className="text-xs text-muted-foreground">Action</span>
|
||||
<Select value={action} onValueChange={setAction}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="All actions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All actions</SelectItem>
|
||||
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
|
||||
<SelectItem key={key} value={key}>{cfg.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">From</span>
|
||||
<DatePickerButton value={from} onChange={setFrom} placeholder="Start date" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">To</span>
|
||||
<DatePickerButton value={to} onChange={setTo} placeholder="End date" disabled={from ? { before: from } : undefined} />
|
||||
</div>
|
||||
|
||||
{(action !== "all" || from || to) && (
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => { setAction("all"); setFrom(null); setTo(null); }}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Table ─────────────────────────────────────────────────── */}
|
||||
<div className="bg-card border rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/40">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">User</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Role</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Action</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Entity</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Time</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading ? (
|
||||
[...Array(8)].map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-3"><Skeleton className="h-4 w-36" /></td>
|
||||
<td className="px-4 py-3"><Skeleton className="h-4 w-16" /></td>
|
||||
<td className="px-4 py-3"><Skeleton className="h-5 w-24 rounded-full" /></td>
|
||||
<td className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
|
||||
<td className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
|
||||
<td className="px-4 py-3" />
|
||||
</tr>
|
||||
))
|
||||
) : activity.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground text-sm">
|
||||
No activity found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
activity.map((row) => (
|
||||
<ActivityRow
|
||||
key={row.activity_id}
|
||||
row={row}
|
||||
onViewUser={() => navigate(`/admin/users/view/${row.user_id}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ─── Pagination ────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-muted/20 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{activityPagination.totalRecords > 0
|
||||
? `Page ${activityPagination.page} of ${activityPagination.totalPages} · ${activityPagination.totalRecords} total`
|
||||
: "No results"}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={!activityPagination.hasPrevPage || loading}
|
||||
onClick={() => load(activityPagination.page - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={!activityPagination.hasNextPage || loading}
|
||||
onClick={() => load(activityPagination.page + 1)}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── DatePickerButton ─────────────────────────────────────────────────────────
|
||||
function DatePickerButton({ value, onChange, placeholder, disabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const label = value
|
||||
? value.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-8 text-sm w-[150px] justify-start font-normal gap-2 ${!value ? "text-muted-foreground" : ""}`}
|
||||
>
|
||||
<CalendarIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={(d) => { onChange(d ?? null); setOpen(false); }}
|
||||
disabled={disabled}
|
||||
initialFocus
|
||||
/>
|
||||
<div className="border-t px-3 py-2 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={() => { onChange(new Date()); setOpen(false); }}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
{value && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs text-muted-foreground"
|
||||
onClick={() => { onChange(null); setOpen(false); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Row ──────────────────────────────────────────────────────────────────────
|
||||
function initials(name, email) {
|
||||
if (name) return name.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase();
|
||||
return (email?.[0] ?? "?").toUpperCase();
|
||||
}
|
||||
|
||||
function ActivityRow({ row, onViewUser }) {
|
||||
const { label, className } = getActionBadge(row.action);
|
||||
const ts = row.created_at;
|
||||
const displayName = row.full_name ?? row.email ?? `User #${row.user_id}`;
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar size="sm" className="shrink-0">
|
||||
<AvatarImage src={row.avatar_url ?? undefined} alt={row.full_name ?? row.email} />
|
||||
<AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground">
|
||||
{initials(row.full_name, row.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium text-sm truncate max-w-[200px]">{displayName}</span>
|
||||
{row.full_name && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[200px]">{row.email}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="capitalize text-xs">{row.acc_type ?? "—"}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">
|
||||
{row.entity_type
|
||||
? <span className="capitalize">{row.entity_type}{row.entity_id ? ` #${row.entity_id}` : ""}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs whitespace-nowrap">
|
||||
{ts ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{new Date(ts).toLocaleString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
hour: "numeric", minute: "2-digit", second: "2-digit",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="opacity-50 hover:opacity-100" onClick={onViewUser}>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View user</TooltipContent>
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useUsers } from "@/contexts/AdminUserContext";
|
||||
import { House, ArrowLeft, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
||||
import { timeAgo } from "@/utils/timestamp.util";
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
export default function UserActivityPage() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, fetchUser, fetchUserActivity, activity, activityPagination, activityLoading } = useUsers();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [action, setAction] = useState("all");
|
||||
|
||||
const load = useCallback(
|
||||
(p = 1) => {
|
||||
fetchUserActivity(userId, {
|
||||
page: p,
|
||||
limit: LIMIT,
|
||||
action: action === "all" ? undefined : action,
|
||||
});
|
||||
setPage(p);
|
||||
},
|
||||
[fetchUserActivity, userId, action]
|
||||
);
|
||||
|
||||
useEffect(() => { fetchUser(userId); }, [userId]);
|
||||
useEffect(() => { load(1); }, [action, userId]);
|
||||
|
||||
const displayName = user?.personal_info?.name?.full_name ?? user?.email ?? `User #${userId}`;
|
||||
|
||||
const breadcrumb = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Users", to: "/admin/users" },
|
||||
{ label: displayName, to: `/admin/users/view/${userId}` },
|
||||
{ label: "Activity" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 pb-10">
|
||||
|
||||
{/* ─── Header ──────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={breadcrumb} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users/view/${userId}`)}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Activity — {displayName}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activityPagination.totalRecords} event{activityPagination.totalRecords !== 1 ? "s" : ""} recorded
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => load(page)} disabled={activityLoading}>
|
||||
<RefreshCw className={`size-4 mr-2 ${activityLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ─── Filter ──────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 bg-card border rounded-lg p-4">
|
||||
<div className="flex flex-col gap-1 min-w-[180px]">
|
||||
<span className="text-xs text-muted-foreground">Filter by action</span>
|
||||
<Select value={action} onValueChange={setAction}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="All actions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All actions</SelectItem>
|
||||
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
|
||||
<SelectItem key={key} value={key}>{cfg.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{action !== "all" && (
|
||||
<div className="flex items-end pt-5">
|
||||
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setAction("all")}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Timeline ────────────────────────────────────────────── */}
|
||||
<div className="bg-card border rounded-lg overflow-hidden">
|
||||
{activityLoading ? (
|
||||
<div className="p-6 space-y-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-5 w-24 rounded-full shrink-0" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-28 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : activity.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-muted-foreground text-sm">
|
||||
No activity recorded{action !== "all" ? " for this filter" : ""}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{activity.map((row) => (
|
||||
<ActivityItem key={row.activity_id} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Pagination ──────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-muted/20 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{activityPagination.totalRecords > 0
|
||||
? `Page ${activityPagination.page} of ${activityPagination.totalPages} · ${activityPagination.totalRecords} total`
|
||||
: "No results"}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={!activityPagination.hasPrevPage || activityLoading}
|
||||
onClick={() => load(activityPagination.page - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={!activityPagination.hasNextPage || activityLoading}
|
||||
onClick={() => load(activityPagination.page + 1)}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Item ──────────────────────────────────────────────────────────────────────
|
||||
function ActivityItem({ row }) {
|
||||
const { label, className } = getActionBadge(row.action);
|
||||
const ts = row.created_at;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-5 py-3 hover:bg-muted/30 transition-colors">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border shrink-0 ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{row.entity_type ? (
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{row.entity_type}{row.entity_id ? ` #${row.entity_id}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40">—</span>
|
||||
)}
|
||||
|
||||
{row.details && Object.keys(row.details).length > 0 && (
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline truncate max-w-xs">
|
||||
{Object.entries(row.details)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="ml-auto text-xs whitespace-nowrap shrink-0">
|
||||
{ts ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{new Date(ts).toLocaleString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
hour: "numeric", minute: "2-digit", second: "2-digit",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">—</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// modules/admin/pages/advertisements/AddAdvertisement.jsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { House, Plus, Trash2, ImagePlus } from "lucide-react";
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
|
||||
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
|
||||
|
||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
|
||||
badge_label: z.string().optional(),
|
||||
headline: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
image_asset_id: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
|
||||
ctas: z.array(z.object({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
link: z.string().min(1, "Link is required."),
|
||||
variant: z.enum(["default", "outline"]).default("default"),
|
||||
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
is_active: z.boolean().default(true),
|
||||
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.too_big,
|
||||
maximum: 200,
|
||||
type: "string",
|
||||
inclusive: true,
|
||||
message: "Description must be 200 characters or fewer for hero placements.",
|
||||
path: ["description"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, description, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5 pb-1 border-b">
|
||||
{title && <h2 className="text-sm font-semibold">{title}</h2>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BANNER_SIZES = [
|
||||
{ value: "sm", label: "Small" },
|
||||
{ value: "md", label: "Medium" },
|
||||
{ value: "lg", label: "Large" },
|
||||
];
|
||||
|
||||
const CTA_VARIANTS = [
|
||||
{ value: "default", label: "Primary" },
|
||||
{ value: "outline", label: "Outline" },
|
||||
];
|
||||
|
||||
// ─── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AddAdvertisement() {
|
||||
const navigate = useNavigate();
|
||||
const { createAdvertisement, loading } = useAdvertisements();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: undefined,
|
||||
badge_label: "",
|
||||
headline: "",
|
||||
description: "",
|
||||
image_asset_id: null,
|
||||
ctas: [],
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
order: 0,
|
||||
is_active: true,
|
||||
size: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
||||
|
||||
const type = watch("type");
|
||||
const description = watch("description");
|
||||
const showRichContent = RICH_CONTENT_TYPES.includes(type);
|
||||
const isBanner = type === "banner";
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Advertisements", to: "/admin/advertisements" },
|
||||
{ label: "New" },
|
||||
];
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const payload = {
|
||||
...values,
|
||||
image_asset_id: values.image_asset_id || null,
|
||||
start_date: values.start_date || null,
|
||||
end_date: values.end_date || null,
|
||||
size: values.type === "banner" ? (values.size || "md") : null,
|
||||
createdBy: user?.user_id ?? null,
|
||||
};
|
||||
|
||||
const res = await createAdvertisement(payload);
|
||||
if (res) navigate("/admin/advertisements");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
|
||||
<div className="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-2xl pb-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">Create a banner, popup, or hero placement.</p>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
<SectionCard title="Placement" description="Where this advertisement will appear.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ADVERTISEMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.type?.message} />
|
||||
{type && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isBanner && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Size</Label>
|
||||
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BANNER_SIZES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Controls the banner's height. Width always stretches full-width.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showRichContent && (
|
||||
<SectionCard title="Content" description="Headline, description, and badge text shown on the hero placement.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Badge label</Label>
|
||||
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline</Label>
|
||||
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Description</Label>
|
||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||
{type === "hero" && (
|
||||
<div className="flex justify-between items-start mt-1">
|
||||
<FieldError message={errors.description?.message} />
|
||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{description?.length ?? 0}/200
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{!showRichContent && (
|
||||
<SectionCard title="Content" description="Optional headline for this placement.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline (optional)</Label>
|
||||
<Input placeholder="Internal label for this ad" {...register("headline")} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
|
||||
{selectedAsset ? (
|
||||
<div
|
||||
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<img
|
||||
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
|
||||
alt={selectedAsset.display_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ImagePlus className="size-5" />
|
||||
<span className="text-sm">Select an image</span>
|
||||
</button>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showRichContent && (
|
||||
<SectionCard
|
||||
title="Calls to action"
|
||||
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
|
||||
>
|
||||
{ctaFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.label?.message} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.link?.message} />
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Select
|
||||
value={watch(`ctas.${index}.variant`) ?? "default"}
|
||||
onValueChange={(v) => setValue(`ctas.${index}.variant`, v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CTA_VARIANTS.map((v) => (
|
||||
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{ctaFields.length < MAX_CTAS ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add CTA
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Start date</Label>
|
||||
<Input type="datetime-local" {...register("start_date")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">End date</Label>
|
||||
<Input type="datetime-local" {...register("end_date")} />
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Display" description="Manual ordering and on/off switch.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Order</Label>
|
||||
<Input type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
||||
<Label className="text-sm">Active</Label>
|
||||
<Switch
|
||||
checked={watch("is_active")}
|
||||
onCheckedChange={(v) => setValue("is_active", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create advertisement
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="image"
|
||||
onSelect={(asset) => {
|
||||
setSelectedAsset(asset);
|
||||
setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// modules/admin/pages/advertisements/AdvertisementList.jsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from "lucide-react";
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
||||
|
||||
export default function AdvertisementList() {
|
||||
const navigate = useNavigate();
|
||||
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
|
||||
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const filters = [];
|
||||
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
|
||||
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
|
||||
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
|
||||
|
||||
fetchAdvertisements({ page: 1, limit: 24, filters });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [typeFilter, statusFilter, search]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Advertisements" },
|
||||
];
|
||||
|
||||
const total = pagination?.totalRecords ?? advertisements.length;
|
||||
const activeCount = advertisements.filter((a) => a.status === "active").length;
|
||||
const scheduledCount = advertisements.filter((a) => a.status === "scheduled").length;
|
||||
const expiredCount = advertisements.filter((a) => a.status === "expired").length;
|
||||
|
||||
async function handleArchive(advertisementId) {
|
||||
await archiveAdvertisement(advertisementId);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-6 pb-10">
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage public-facing banners, popups, and promotional placements</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/admin/advertisements/add")}>
|
||||
<Plus className="size-4" />
|
||||
New advertisement
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Stat cards ─────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard label="Total ads" value={total} />
|
||||
<StatCard label="Active" value={activeCount} tone="success" />
|
||||
<StatCard label="Scheduled" value={scheduledCount} tone="info" />
|
||||
<StatCard label="Expired" value={expiredCount} tone="muted" />
|
||||
</div>
|
||||
|
||||
{/* ── Filters ────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{ADVERTISEMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{ADVERTISEMENT_STATUSES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="relative flex-1 min-w-[160px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search advertisements..."
|
||||
className="pl-8"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Grid ───────────────────────────────────────────────────── */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : advertisements.length === 0 ? (
|
||||
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{advertisements.map((ad) => (
|
||||
<AdvertisementCard
|
||||
key={ad.advertisement_id}
|
||||
ad={ad}
|
||||
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
|
||||
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
|
||||
onArchive={() => handleArchive(ad.advertisement_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, tone = "default" }) {
|
||||
const toneClass = {
|
||||
default: "text-foreground",
|
||||
success: "text-green-600 dark:text-green-400",
|
||||
info: "text-blue-600 dark:text-blue-400",
|
||||
muted: "text-muted-foreground",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className="bg-background rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-2xl font-semibold ${toneClass}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Advertisement card ─────────────────────────────────────────────────────
|
||||
|
||||
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
||||
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
|
||||
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
|
||||
const TypeIcon = typeMeta.icon ?? Megaphone;
|
||||
|
||||
const previewSrc = ad.image?.thumbnail_url || ad.image?.file_url || ad.image_url || null;
|
||||
const isDimmed = ad.status === "expired" || ad.status === "archived";
|
||||
|
||||
const dateRange = formatDateRange(ad.start_date, ad.end_date);
|
||||
|
||||
return (
|
||||
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onView}
|
||||
className="h-32 bg-muted relative flex items-center justify-center w-full text-left cursor-pointer"
|
||||
aria-label="View advertisement details"
|
||||
>
|
||||
{previewSrc ? (
|
||||
<img src={previewSrc} alt={ad.headline || ad.type} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-7 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<span className={`absolute top-2 left-2 text-xs font-medium px-2 py-0.5 rounded-md ${statusMeta.badgeClass ?? "bg-muted text-muted-foreground"}`}>
|
||||
{statusMeta.label ?? ad.status}
|
||||
</span>
|
||||
<span className="absolute top-2 right-2 flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-md bg-black/55 text-white">
|
||||
<TypeIcon className="size-3" />
|
||||
{typeMeta.label ?? ad.type}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="p-3 flex flex-col gap-2 flex-1">
|
||||
<button type="button" onClick={onView} className="text-left">
|
||||
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled advertisement"}</p>
|
||||
{dateRange && <p className="text-xs text-muted-foreground mt-0.5">{dateRange}</p>}
|
||||
</button>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between text-xs text-muted-foreground pt-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<MousePointerClick className="size-3.5" />
|
||||
{ad.click_count ?? 0} clicks
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
|
||||
<Edit className="size-3.5" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-7" aria-label="Delete">
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive this advertisement?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{ad.headline || ad.badge_label || "This advertisement"}" will be moved to archived advertisements. You can restore it later.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onArchive}>Archive</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty state ────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({ onCreate }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
|
||||
<Megaphone className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">No advertisements yet</p>
|
||||
<p className="text-sm text-muted-foreground">Create your first banner, popup, or hero placement.</p>
|
||||
</div>
|
||||
<Button onClick={onCreate}>
|
||||
<Plus className="size-4" />
|
||||
New advertisement
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDateRange(start, end) {
|
||||
if (!start && !end) return null;
|
||||
const fmt = (d) => new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
|
||||
if (start && end) return `${fmt(start)} - ${fmt(end)}`;
|
||||
if (start) return `Starts ${fmt(start)}`;
|
||||
if (end) return `Ends ${fmt(end)}`;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
// modules/admin/pages/advertisements/EditAdvertisement.jsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { House, Plus, Trash2, ImagePlus } from "lucide-react";
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
|
||||
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
|
||||
|
||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
|
||||
badge_label: z.string().optional(),
|
||||
headline: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
image_asset_id: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
|
||||
ctas: z.array(z.object({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
link: z.string().min(1, "Link is required."),
|
||||
variant: z.enum(["default", "outline"]).default("default"),
|
||||
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
is_active: z.boolean().default(true),
|
||||
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.too_big,
|
||||
maximum: 200,
|
||||
type: "string",
|
||||
inclusive: true,
|
||||
message: "Description must be 200 characters or fewer for hero placements.",
|
||||
path: ["description"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, description, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5 pb-1 border-b">
|
||||
{title && <h2 className="text-sm font-semibold">{title}</h2>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Convert ISO datetime to value usable by <input type="datetime-local">
|
||||
function toLocalInputValue(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const BANNER_SIZES = [
|
||||
{ value: "sm", label: "Small" },
|
||||
{ value: "md", label: "Medium" },
|
||||
{ value: "lg", label: "Large" },
|
||||
];
|
||||
|
||||
const CTA_VARIANTS = [
|
||||
{ value: "default", label: "Primary" },
|
||||
{ value: "outline", label: "Outline" },
|
||||
];
|
||||
|
||||
// ─── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EditAdvertisement() {
|
||||
const navigate = useNavigate();
|
||||
const { advertisementId } = useParams();
|
||||
const { fetchAdvertisement, updateAdvertisement, loading } = useAdvertisements();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: undefined,
|
||||
badge_label: "",
|
||||
headline: "",
|
||||
description: "",
|
||||
image_asset_id: null,
|
||||
ctas: [],
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
order: 0,
|
||||
is_active: true,
|
||||
size: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
||||
|
||||
const type = watch("type");
|
||||
const description = watch("description");
|
||||
const showRichContent = RICH_CONTENT_TYPES.includes(type);
|
||||
const isBanner = type === "banner";
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Advertisements", to: "/admin/advertisements" },
|
||||
{ label: "Edit" },
|
||||
];
|
||||
|
||||
// ─── Load existing advertisement data ────────────────────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchAdvertisement(advertisementId);
|
||||
const ad = res?.data?.data ?? null;
|
||||
if (!ad) return;
|
||||
|
||||
if (ad.image) setSelectedAsset(ad.image);
|
||||
|
||||
reset({
|
||||
type: ad.type ?? undefined,
|
||||
badge_label: ad.badge_label ?? "",
|
||||
headline: ad.headline ?? "",
|
||||
description: ad.description ?? "",
|
||||
image_asset_id: ad.image?.asset_id ?? null,
|
||||
ctas: (ad.ctas ?? []).map((c, i) => ({
|
||||
label: c.label ?? "",
|
||||
link: c.link ?? "",
|
||||
variant: c.variant ?? (i === 0 ? "default" : "outline"),
|
||||
})),
|
||||
start_date: toLocalInputValue(ad.start_date),
|
||||
end_date: toLocalInputValue(ad.end_date),
|
||||
order: ad.order ?? 0,
|
||||
is_active: ad.is_active ?? true,
|
||||
size: ad.size ?? null,
|
||||
});
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [advertisementId]);
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
|
||||
const payload = {
|
||||
...values,
|
||||
image_asset_id: values.image_asset_id || null,
|
||||
start_date: values.start_date || null,
|
||||
end_date: values.end_date || null,
|
||||
size: values.type === "banner" ? (values.size || "md") : null,
|
||||
updatedBy: user?.user_id ?? null,
|
||||
};
|
||||
|
||||
const res = await updateAdvertisement(advertisementId, payload);
|
||||
if (res) navigate("/admin/advertisements");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
|
||||
<div className="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-2xl pb-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit advertisement</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">Update this banner, popup, or hero placement.</p>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
<SectionCard title="Placement" description="Where this advertisement will appear.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true, shouldDirty: true })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ADVERTISEMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.type?.message} />
|
||||
{type && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isBanner && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Size</Label>
|
||||
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v, { shouldDirty: true })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BANNER_SIZES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Controls the banner's height. Width always stretches full-width.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showRichContent && (
|
||||
<SectionCard title="Content" description="Headline, description, and badge text shown on the hero placement.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Badge label</Label>
|
||||
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline</Label>
|
||||
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Description</Label>
|
||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||
{type === "hero" && (
|
||||
<div className="flex justify-between items-start mt-1">
|
||||
<FieldError message={errors.description?.message} />
|
||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{description?.length ?? 0}/200
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{!showRichContent && (
|
||||
<SectionCard title="Content" description="Optional headline for this placement.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline (optional)</Label>
|
||||
<Input placeholder="Internal label for this ad" {...register("headline")} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
|
||||
{selectedAsset ? (
|
||||
<div
|
||||
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<img
|
||||
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
|
||||
alt={selectedAsset.display_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ImagePlus className="size-5" />
|
||||
<span className="text-sm">Select an image</span>
|
||||
</button>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showRichContent && (
|
||||
<SectionCard
|
||||
title="Calls to action"
|
||||
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
|
||||
>
|
||||
{ctaFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.label?.message} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.link?.message} />
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Select
|
||||
value={watch(`ctas.${index}.variant`) ?? "default"}
|
||||
onValueChange={(v) => setValue(`ctas.${index}.variant`, v, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CTA_VARIANTS.map((v) => (
|
||||
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{ctaFields.length < MAX_CTAS ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add CTA
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Start date</Label>
|
||||
<Input type="datetime-local" {...register("start_date")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">End date</Label>
|
||||
<Input type="datetime-local" {...register("end_date")} />
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Display" description="Manual ordering and on/off switch.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Order</Label>
|
||||
<Input type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
||||
<Label className="text-sm">Active</Label>
|
||||
<Switch
|
||||
checked={watch("is_active")}
|
||||
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="image"
|
||||
onSelect={(asset) => {
|
||||
setSelectedAsset(asset);
|
||||
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// modules/admin/pages/advertisements/ViewAdvertisement.jsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { House, Edit, ArrowLeft, Megaphone, MousePointerClick, ExternalLink } from "lucide-react";
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
import { ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionCard({ title, description, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5 pb-1 border-b">
|
||||
{title && <h2 className="text-sm font-semibold">{title}</h2>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-0.5">{label}</p>
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(iso) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ViewAdvertisement() {
|
||||
const navigate = useNavigate();
|
||||
const { advertisementId } = useParams();
|
||||
const { fetchAdvertisement, loading } = useAdvertisements();
|
||||
|
||||
const [advertisement, setAdvertisement] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchAdvertisement(advertisementId);
|
||||
setAdvertisement(res?.data?.data ?? null);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [advertisementId]);
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Advertisements", to: "/admin/advertisements" },
|
||||
{ label: "View" },
|
||||
];
|
||||
|
||||
if (loading && !advertisement) {
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!advertisement) {
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Advertisement not found.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const typeMeta = ADVERTISEMENT_TYPE_MAP[advertisement.type] ?? {};
|
||||
const statusMeta = ADVERTISEMENT_STATUS_MAP[advertisement.status] ?? {};
|
||||
const TypeIcon = typeMeta.icon ?? Megaphone;
|
||||
|
||||
const previewSrc = advertisement.image?.thumbnail_url || advertisement.image?.file_url || advertisement.image_url || null;
|
||||
const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : [];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
|
||||
<div className="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-2xl pb-10 space-y-5">
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/advertisements")} aria-label="Back">
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{advertisement.headline || advertisement.badge_label || "Untitled advertisement"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<TypeIcon className="size-3" />
|
||||
{typeMeta.label ?? advertisement.type}
|
||||
</Badge>
|
||||
<Badge variant={advertisement.status === "active" ? "default" : "secondary"}>
|
||||
{statusMeta.label ?? advertisement.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/admin/advertisements/${advertisementId}/edit`)}>
|
||||
<Edit className="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Preview ────────────────────────────────────────────────── */}
|
||||
<SectionCard title="Preview">
|
||||
<div className="h-48 rounded-lg bg-muted flex items-center justify-center overflow-hidden">
|
||||
{previewSrc ? (
|
||||
<img src={previewSrc} alt={advertisement.headline || advertisement.type} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Content ────────────────────────────────────────────────── */}
|
||||
<SectionCard title="Content">
|
||||
<Field label="Badge label">{advertisement.badge_label || "—"}</Field>
|
||||
<Field label="Headline">{advertisement.headline || "—"}</Field>
|
||||
<Field label="Description">{advertisement.description || "—"}</Field>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Calls to action ────────────────────────────────────────── */}
|
||||
{ctas.length > 0 && (
|
||||
<SectionCard title="Calls to action">
|
||||
<div className="flex flex-col gap-2">
|
||||
{ctas.map((cta, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm border rounded-md px-3 py-2">
|
||||
<span className="font-medium">{cta.label}</span>
|
||||
<a href={cta.link} target="_blank" rel="noreferrer" className="text-muted-foreground flex items-center gap-1 hover:text-foreground">
|
||||
{cta.link}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Scheduling & display ──────────────────────────────────── */}
|
||||
<SectionCard title="Scheduling & display">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Start date">{formatDateTime(advertisement.start_date)}</Field>
|
||||
<Field label="End date">{formatDateTime(advertisement.end_date)}</Field>
|
||||
<Field label="Order">{advertisement.order ?? 0}</Field>
|
||||
<Field label="Active">{advertisement.is_active ? "Yes" : "No"}</Field>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Metrics ────────────────────────────────────────────────── */}
|
||||
<SectionCard title="Metrics">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MousePointerClick className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{advertisement.click_count ?? 0}</span>
|
||||
<span className="text-muted-foreground">clicks</span>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Audit ──────────────────────────────────────────────────── */}
|
||||
<SectionCard title="Audit">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Created by">{advertisement.creator?.full_name || "—"}</Field>
|
||||
<Field label="Created at">{formatDateTime(advertisement.createdAt)}</Field>
|
||||
<Field label="Last updated by">{advertisement.updater?.full_name || "—"}</Field>
|
||||
<Field label="Last updated at">{formatDateTime(advertisement.updatedAt)}</Field>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image } from "lucide-react";
|
||||
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
@@ -25,10 +25,11 @@ import {
|
||||
// ─── Derive file_type from MIME type ──────────────────────────────────────────
|
||||
|
||||
function resolveFileType(mimeType = "") {
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("audio/")) return "audio";
|
||||
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
|
||||
return "image";
|
||||
return "document";
|
||||
}
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
@@ -45,6 +46,7 @@ const schema = z.object({
|
||||
function FileTypeIcon({ mimeType = "" }) {
|
||||
if (mimeType.startsWith("video/")) return <FileVideo className="h-10 w-10 text-blue-400" />;
|
||||
if (mimeType.startsWith("image/")) return <Image className="h-10 w-10 text-green-400" />;
|
||||
if (mimeType.startsWith("audio/")) return <FileAudio className="h-10 w-10 text-purple-400" />;
|
||||
return <FileText className="h-10 w-10 text-orange-400" />;
|
||||
}
|
||||
|
||||
@@ -116,7 +118,7 @@ export default function AddAsset() {
|
||||
const { uploadAsset, loading } = useAssets();
|
||||
const { user } = useAuth();
|
||||
|
||||
const fileRef = useRef(null);
|
||||
const fileRef = useRef(null);
|
||||
const thumbnailRef = useRef(null);
|
||||
const [thumbKey, setThumbKey] = useState(0);
|
||||
|
||||
@@ -135,12 +137,13 @@ export default function AddAsset() {
|
||||
display_name: "",
|
||||
description: "",
|
||||
is_public: "false",
|
||||
storage_provider: "chibisafe",
|
||||
storage_provider: "s3", // ← changed from "chibisafe"
|
||||
},
|
||||
});
|
||||
|
||||
const file = watch("_file");
|
||||
const isVideo = file?.type?.startsWith("video/");
|
||||
const isAudio = file?.type?.startsWith("audio/");
|
||||
|
||||
// ── Auto-derive file_type from MIME ───────────────────────────────────────
|
||||
const fileType = file ? resolveFileType(file.type) : null;
|
||||
@@ -166,6 +169,7 @@ export default function AddAsset() {
|
||||
hasFileError = true;
|
||||
}
|
||||
|
||||
// Thumbnail is required for video, optional for audio
|
||||
if (isVideo && !thumbnailRef.current) {
|
||||
setError("_thumbnail", { message: "A thumbnail is required for video uploads." });
|
||||
hasFileError = true;
|
||||
@@ -208,8 +212,8 @@ export default function AddAsset() {
|
||||
<div className="space-y-1.5">
|
||||
<Label>File <span className="text-destructive">*</span></Label>
|
||||
<DropZone
|
||||
label="Images, videos, documents"
|
||||
accept="image/*,video/*,application/*,text/*"
|
||||
label="Images, videos, audio, documents"
|
||||
accept="image/*,video/*,audio/*,application/*,text/*"
|
||||
file={fileRef.current}
|
||||
onFile={setFile}
|
||||
onClear={() => {
|
||||
@@ -223,13 +227,19 @@ export default function AddAsset() {
|
||||
<FieldError message={errors._file?.message} />
|
||||
</div>
|
||||
|
||||
{/* ── Thumbnail (video only) ── */}
|
||||
{isVideo && (
|
||||
{/* ── Thumbnail (video required / audio optional) ── */}
|
||||
{(isVideo || isAudio) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Thumbnail <span className="text-destructive">*</span></Label>
|
||||
<Label>
|
||||
Thumbnail{" "}
|
||||
{isVideo
|
||||
? <span className="text-destructive">*</span>
|
||||
: <span className="text-muted-foreground text-xs">(optional — album / cover art)</span>
|
||||
}
|
||||
</Label>
|
||||
<DropZone
|
||||
key={thumbKey}
|
||||
label="JPEG, PNG (video thumbnail)"
|
||||
label="JPEG, PNG"
|
||||
accept="image/*"
|
||||
file={thumbnailRef.current}
|
||||
onFile={setThumbnail}
|
||||
@@ -283,6 +293,7 @@ export default function AddAsset() {
|
||||
<SelectItem value="avatar">Avatar</SelectItem>
|
||||
<SelectItem value="image">Image</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="document">Document</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
|
||||
|
||||
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
|
||||
|
||||
function MetaRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
||||
<span className="text-sm font-medium break-all">{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ViewAudioAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (assetId) fetchAsset(assetId);
|
||||
}, [assetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAsset) return;
|
||||
setStreamUrl(null);
|
||||
setThumbnailUrl(null);
|
||||
if (selectedAsset.storage_provider !== "s3") {
|
||||
setStreamUrl(selectedAsset.file_url ?? null);
|
||||
return;
|
||||
}
|
||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
|
||||
.then(({ data }) => {
|
||||
const { token, thumbnail_url } = data?.data ?? {};
|
||||
if (token) setStreamUrl(`${API_BASE}/client/media/stream/${token}`);
|
||||
if (thumbnail_url) setThumbnailUrl(thumbnail_url);
|
||||
})
|
||||
.catch(() => setStreamUrl(null));
|
||||
}, [selectedAsset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedAsset) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||
<p>Asset not found.</p>
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const a = selectedAsset;
|
||||
|
||||
const audioContent = {
|
||||
url: streamUrl,
|
||||
title: a.display_name ?? a.original_name,
|
||||
artist: a.description ?? "",
|
||||
thumbnail: thumbnailUrl ?? a.thumbnail_url ?? null,
|
||||
tag: a.extension?.toUpperCase() ?? "",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
|
||||
{/* ── Audio player ── */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{streamUrl ? (
|
||||
<AudioBlock content={audioContent} />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-muted/30 flex flex-col items-center justify-center gap-4 py-20">
|
||||
<Music2 className="h-16 w-16 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground text-sm">Audio file URL not available.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Metadata panel ── */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
||||
<MetaRow label="Original Name" value={a.original_name} />
|
||||
<MetaRow label="Extension" value={a.extension} />
|
||||
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
|
||||
<MetaRow label="MIME Type" value={a.mime_type} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
||||
<MetaRow label="Provider" value={a.storage_provider} />
|
||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||
<MetaRow label="Key" value={a.storage_key} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<MetaRow label="Access Level" value={a.access_level} />
|
||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
||||
<MetaRow label="Created By" value={a.createdBy} />
|
||||
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
||||
<p className="text-sm text-foreground">{a.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -27,11 +28,26 @@ export default function ViewDocumentAsset() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (assetId) fetchAsset(assetId);
|
||||
}, [assetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAsset) return;
|
||||
setStreamUrl(null);
|
||||
if (selectedAsset.storage_provider !== "s3") {
|
||||
setStreamUrl(selectedAsset.file_url ?? null);
|
||||
return;
|
||||
}
|
||||
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
|
||||
.then(({ data }) => setStreamUrl(
|
||||
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
|
||||
))
|
||||
.catch(() => setStreamUrl(null));
|
||||
}, [selectedAsset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
@@ -70,10 +86,10 @@ export default function ViewDocumentAsset() {
|
||||
|
||||
{/* ── Document preview ── */}
|
||||
<div className="lg:col-span-3">
|
||||
{canPreview && a.file_url ? (
|
||||
{canPreview && streamUrl ? (
|
||||
<div className="rounded-lg border overflow-hidden bg-white" style={{ height: 600 }}>
|
||||
<iframe
|
||||
src={a.file_url}
|
||||
src={streamUrl}
|
||||
title={a.display_name ?? a.original_name}
|
||||
className="w-full h-full"
|
||||
// sandbox="allow-scripts allow-same-origin"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// modules/admin/pages/assets/ViewImageAsset.jsx
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "@/utils/api.util";
|
||||
import { ArrowLeft, Lock, Globe } from "lucide-react";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
@@ -25,11 +26,26 @@ export default function ViewImageAsset() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (assetId) fetchAsset(assetId);
|
||||
}, [assetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAsset) return;
|
||||
setStreamUrl(null);
|
||||
if (selectedAsset.storage_provider !== "s3") {
|
||||
setStreamUrl(selectedAsset.file_url ?? null);
|
||||
return;
|
||||
}
|
||||
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
|
||||
.then(({ data }) => setStreamUrl(
|
||||
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
|
||||
))
|
||||
.catch(() => setStreamUrl(null));
|
||||
}, [selectedAsset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
@@ -67,9 +83,9 @@ export default function ViewImageAsset() {
|
||||
|
||||
{/* ── Image preview ── */}
|
||||
<div className="lg:col-span-3 rounded-lg border bg-muted/30 overflow-hidden flex items-center justify-center min-h-64">
|
||||
{a.file_url ? (
|
||||
{streamUrl ? (
|
||||
<img
|
||||
src={a.file_url}
|
||||
src={streamUrl}
|
||||
alt={a.display_name ?? a.original_name}
|
||||
className="max-w-full max-h-[520px] object-contain"
|
||||
draggable={false}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -35,11 +36,26 @@ export default function ViewVideoAsset() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (assetId) fetchAsset(assetId);
|
||||
}, [assetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAsset) return;
|
||||
setStreamUrl(null);
|
||||
if (selectedAsset.storage_provider !== "s3") {
|
||||
setStreamUrl(selectedAsset.file_url ?? null);
|
||||
return;
|
||||
}
|
||||
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
|
||||
.then(({ data }) => setStreamUrl(
|
||||
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
|
||||
))
|
||||
.catch(() => setStreamUrl(null));
|
||||
}, [selectedAsset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
@@ -78,17 +94,17 @@ export default function ViewVideoAsset() {
|
||||
{/* ── Video player ── */}
|
||||
<div className="lg:col-span-3 space-y-3">
|
||||
<div className="rounded-lg border bg-black overflow-hidden aspect-video flex items-center justify-center">
|
||||
{a.file_url ? (
|
||||
{streamUrl ? (
|
||||
<video
|
||||
key={a.file_url}
|
||||
key={streamUrl}
|
||||
controls
|
||||
controlsList="nodownload" // ← hides download button in browser controls
|
||||
disablePictureInPicture // ← hides PiP button
|
||||
onContextMenu={(e) => e.preventDefault()} // ← disables right-click save
|
||||
controlsList="nodownload"
|
||||
disablePictureInPicture
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full h-full"
|
||||
poster={a.thumbnail_url ?? undefined}
|
||||
>
|
||||
<source src={a.file_url} type={a.mime_type ?? "video/mp4"} />
|
||||
<source src={streamUrl} type={a.mime_type ?? "video/mp4"} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required."),
|
||||
description: z.string().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{title && <div className="pb-1 border-b"><h2 className="text-sm font-semibold">{title}</h2></div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AddCategory() {
|
||||
const navigate = useNavigate();
|
||||
const { createCategory, loading } = useCategories();
|
||||
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { name: "", description: "", is_active: true },
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const result = await createCategory(values);
|
||||
if (!result) return;
|
||||
navigate("/admin/courses/categories");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<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: "Categories", to: "/admin/courses/categories" },
|
||||
{ label: "Add Category" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Add Category</h1>
|
||||
<p className="text-sm text-muted-foreground">Create a new course category.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<SectionCard title="Category Details">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
|
||||
<Input id="name" placeholder="e.g. Real Estate" {...register("name")} />
|
||||
<FieldError message={errors.name?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Active</Label>
|
||||
<p className="text-xs text-muted-foreground">Visible to learners when browsing.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={watch("is_active")}
|
||||
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Category
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Pencil, Trash2, RotateCcw, Tag } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
||||
|
||||
const BREADCRUMB = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Categories" },
|
||||
];
|
||||
|
||||
export default function CategoryList() {
|
||||
const navigate = useNavigate();
|
||||
const { categories, loading, fetchCategories, archiveCategory, restoreCategory } = useCategories();
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
useEffect(() => { fetchCategories(showArchived); }, [showArchived]);
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={BREADCRUMB} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Categories</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage course categories for browsing and filtering.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowArchived((v) => !v)}
|
||||
>
|
||||
{showArchived ? "Active" : "Archived"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate("/admin/courses/categories/add")}>
|
||||
<Plus className="size-4" /> Add Category
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full rounded-lg border bg-card overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Tag className="size-8 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{showArchived ? "No archived categories." : "No categories yet. Add one to get started."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/40">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Name</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Slug</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{categories.map((cat) => (
|
||||
<tr key={cat.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-medium">{cat.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">{cat.slug}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={cat.is_active ? "default" : "secondary"}>
|
||||
{cat.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!showArchived ? (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={() => navigate(`/admin/courses/categories/${cat.id}/edit`)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 text-destructive hover:text-destructive"
|
||||
onClick={async () => { await archiveCategory(cat.id); }}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 text-emerald-600"
|
||||
onClick={async () => { await restoreCategory(cat.id); }}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required."),
|
||||
description: z.string().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{title && <div className="pb-1 border-b"><h2 className="text-sm font-semibold">{title}</h2></div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditCategory() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { fetchCategory, updateCategory, loading } = useCategories();
|
||||
|
||||
const { register, handleSubmit, reset, watch, setValue, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { name: "", description: "", is_active: true },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const cat = await fetchCategory(id);
|
||||
if (!cat) return;
|
||||
reset({ name: cat.name ?? "", description: cat.description ?? "", is_active: cat.is_active ?? true });
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
const result = await updateCategory(id, values);
|
||||
if (!result) return;
|
||||
navigate("/admin/courses/categories");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<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: "Categories", to: "/admin/courses/categories" },
|
||||
{ label: "Edit Category" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Edit Category</h1>
|
||||
<p className="text-sm text-muted-foreground">Update category details.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<SectionCard title="Category Details">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
|
||||
<Input id="name" {...register("name")} />
|
||||
<FieldError message={errors.name?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Active</Label>
|
||||
<p className="text-xs text-muted-foreground">Visible to learners when browsing.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={watch("is_active")}
|
||||
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
||||
<Button type="submit" disabled={loading || !isDirty}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -99,6 +100,7 @@ export default function AddCourse() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Add Course - STARR" description="Create a new training course." />
|
||||
<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">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { House } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import ArchivedCoursesTable from "../../components/courses/ArchivedCourseTable";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
export default function ArchivedCourseList() {
|
||||
const items = [
|
||||
@@ -11,6 +12,7 @@ export default function ArchivedCourseList() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title="Archived Courses - STARR" description="View archived training courses." />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown } f
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -73,7 +74,7 @@ const TYPE_LABEL = {
|
||||
true_false: "TF",
|
||||
};
|
||||
|
||||
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, errors }) {
|
||||
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
@@ -88,7 +89,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
|
||||
</div>
|
||||
|
||||
{/* Scrollable list */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div ref={navContainerRef} className="flex-1 overflow-y-auto py-2">
|
||||
{questions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-8 px-3">
|
||||
No questions yet.
|
||||
@@ -202,6 +203,7 @@ export default function CourseAssessment() {
|
||||
|
||||
const questionRefs = useRef([]);
|
||||
const navItemRefs = useRef([]);
|
||||
const navContainerRef = useRef(null);
|
||||
const headerRef = useRef(null);
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────────────────
|
||||
@@ -245,10 +247,20 @@ export default function CourseAssessment() {
|
||||
|
||||
// ── Scroll to keep active nav item visible ─────────────────────────────────
|
||||
useEffect(() => {
|
||||
navItemRefs.current[activeIndex]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
const item = navItemRefs.current[activeIndex];
|
||||
const container = navContainerRef.current;
|
||||
if (!item || !container) return;
|
||||
|
||||
const itemTop = item.offsetTop;
|
||||
const itemBottom = itemTop + item.offsetHeight;
|
||||
const viewTop = container.scrollTop;
|
||||
const viewBottom = viewTop + container.clientHeight;
|
||||
|
||||
if (itemTop < viewTop) {
|
||||
container.scrollTop = itemTop;
|
||||
} else if (itemBottom > viewBottom) {
|
||||
container.scrollTop = itemBottom - container.clientHeight;
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
// ── IntersectionObserver — highlight nav as user scrolls ──────────────────
|
||||
@@ -362,12 +374,13 @@ export default function CourseAssessment() {
|
||||
}
|
||||
}
|
||||
|
||||
navigate(-1);
|
||||
// navigate(-1);
|
||||
};
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<PageMeta title={course ? `${course.title} – Assessment - STARR` : undefined} />
|
||||
|
||||
{/* ── Sticky header ── */}
|
||||
<div
|
||||
@@ -415,6 +428,7 @@ export default function CourseAssessment() {
|
||||
onJump={jumpTo}
|
||||
onMove={moveQuestion}
|
||||
navItemRefs={navItemRefs}
|
||||
navContainerRef={navContainerRef}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { House } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import CoursesTable from "../../components/courses/CourseTable";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
export default function CourseList() {
|
||||
const items = [
|
||||
@@ -10,6 +11,7 @@ export default function CourseList() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title="Courses - STARR" description="Browse and manage your training courses." />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, Plus, Trash2, Save, BadgeCheck, GripVertical, Tag, ChevronsUpDown, Check, X } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
|
||||
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -20,6 +25,19 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,11 +48,32 @@ const schema = z.object({
|
||||
order_index: z.coerce.number().min(0).default(0),
|
||||
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
subscription: z.enum(["free", "premium"]).default("free"),
|
||||
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
|
||||
objectives: z
|
||||
.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
|
||||
.default([]),
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const CertBadgeIcon = ({ className }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="120" height="120" rx="26" fill="url(#ec-cert-grad)" />
|
||||
<rect width="120" height="120" rx="26" fill="url(#cd-cert-grad)" />
|
||||
{/* short top bar */}
|
||||
<rect x="30" y="32" width="60" height="16" rx="8" fill="white" fillOpacity="0.95" />
|
||||
{/* longer middle bar */}
|
||||
<rect x="22" y="56" width="76" height="16" rx="8" fill="white" fillOpacity="0.80" />
|
||||
{/* gold bottom bar */}
|
||||
<rect x="19" y="80" width="84" height="14" rx="8" fill="#D4A017" />
|
||||
<defs>
|
||||
<linearGradient id="ec-cert-grad" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#8B9FEE" />
|
||||
<stop offset="1" stopColor="#4F6FD4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
@@ -46,7 +85,9 @@ function SectionCard({ title, description, children }) {
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5 pb-1 border-b">
|
||||
{title && <h2 className="text-sm font-semibold">{title}</h2>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
@@ -59,9 +100,29 @@ function SectionCard({ title, description, children }) {
|
||||
export default function EditCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, updateCourse, loading } = useCourses();
|
||||
const { fetchCourse, updateCourse, fetchCourseProduct, saveCourseProduct, removeCourseProduct, fetchCourseCategories, syncCourseCategories, fetchInstructors, syncInstructors, loading, course } = useCourses();
|
||||
const { categories: allCategories, fetchCategories } = useCategories();
|
||||
const { user } = useAuth();
|
||||
|
||||
// ─── Categories state ─────────────────────────────────────────────────────
|
||||
const [selectedCategoryIds, setSelectedCategoryIds] = useState([]);
|
||||
const [categoriesDirty, setCategoriesDirty] = useState(false);
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(false);
|
||||
const [catOpen, setCatOpen] = useState(false);
|
||||
|
||||
// ─── Instructors state ────────────────────────────────────────────────────
|
||||
const [instructors, setInstructors] = useState([]);
|
||||
const [instructorsDirty, setInstructorsDirty] = useState(false);
|
||||
const [instructorsLoading, setInstructorsLoading] = useState(false);
|
||||
|
||||
// ─── Product state ────────────────────────────────────────────────────────
|
||||
const [product, setProduct] = useState(null);
|
||||
const [productDirty, setProductDirty] = useState(false);
|
||||
const [productLoading, setProductLoading] = useState(false);
|
||||
const [productForm, setProductForm] = useState({
|
||||
name: "", price: "", currency: "USD", access_days: "", is_active: true,
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -83,8 +144,11 @@ export default function EditCourse() {
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
||||
useFieldArray({ control, name: "objectives" });
|
||||
const {
|
||||
fields: objectiveFields,
|
||||
append: appendObjective,
|
||||
remove: removeObjective,
|
||||
} = useFieldArray({ control, name: "objectives" });
|
||||
|
||||
// ─── Load existing course data ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -101,24 +165,128 @@ export default function EditCourse() {
|
||||
level: c.level ?? undefined,
|
||||
subscription: c.subscription ?? "free",
|
||||
objectives: (c.objectives ?? []).map((o) => ({
|
||||
objective_id: o.objective_id ?? null, // ← carry the id
|
||||
value: o.text ?? "", // ← form field is "value"
|
||||
objective_id: o.objective_id ?? null,
|
||||
text: o.text ?? "",
|
||||
})),
|
||||
});
|
||||
})();
|
||||
|
||||
// Load categories, product, and instructors in parallel
|
||||
(async () => {
|
||||
await fetchCategories();
|
||||
const [cats, prod, insts] = await Promise.all([
|
||||
fetchCourseCategories(courseId),
|
||||
fetchCourseProduct(courseId),
|
||||
fetchInstructors(courseId),
|
||||
]);
|
||||
setSelectedCategoryIds((cats ?? []).map((c) => String(c.id)));
|
||||
if (prod) {
|
||||
setProduct(prod);
|
||||
setProductForm({
|
||||
name: prod.name ?? "",
|
||||
price: prod.price ?? "",
|
||||
currency: prod.currency ?? "USD",
|
||||
access_days: prod.access_days ?? "",
|
||||
is_active: prod.is_active ?? true,
|
||||
});
|
||||
}
|
||||
setInstructors(
|
||||
(insts?.data ?? []).map((i) => ({
|
||||
_key: i.id ?? crypto.randomUUID(),
|
||||
user_id: i.user_id ?? null,
|
||||
display_name: i.display_name ?? "",
|
||||
order_index: i.order_index ?? 0,
|
||||
}))
|
||||
);
|
||||
})();
|
||||
}, [courseId]);
|
||||
|
||||
// ─── Category handlers ────────────────────────────────────────────────────
|
||||
const toggleCategory = useCallback((id) => {
|
||||
setSelectedCategoryIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
setCategoriesDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveCategories = async () => {
|
||||
setCategoriesLoading(true);
|
||||
await syncCourseCategories(courseId, selectedCategoryIds.map(Number));
|
||||
setCategoriesDirty(false);
|
||||
setCategoriesLoading(false);
|
||||
};
|
||||
|
||||
// ─── Product handlers ─────────────────────────────────────────────────────
|
||||
const handleProductChange = (field, value) => {
|
||||
setProductForm((prev) => ({ ...prev, [field]: value }));
|
||||
setProductDirty(true);
|
||||
};
|
||||
|
||||
const handleSaveProduct = async () => {
|
||||
if (!productForm.price) return;
|
||||
setProductLoading(true);
|
||||
const saved = await saveCourseProduct(courseId, {
|
||||
name: productForm.name || null,
|
||||
price: Number(productForm.price),
|
||||
currency: productForm.currency || "USD",
|
||||
access_days: productForm.access_days ? Number(productForm.access_days) : null,
|
||||
is_active: productForm.is_active,
|
||||
});
|
||||
if (saved) { setProduct(saved); setProductDirty(false); }
|
||||
setProductLoading(false);
|
||||
};
|
||||
|
||||
const handleRemoveProduct = async () => {
|
||||
setProductLoading(true);
|
||||
await removeCourseProduct(courseId);
|
||||
setProduct(null);
|
||||
setProductForm({ name: "", price: "", currency: "USD", access_days: "", is_active: true });
|
||||
setProductDirty(false);
|
||||
setProductLoading(false);
|
||||
};
|
||||
|
||||
// ─── Instructor handlers ──────────────────────────────────────────────────
|
||||
const addInstructor = useCallback(({ user_id, display_name }) => {
|
||||
setInstructors((prev) => [
|
||||
...prev,
|
||||
{ _key: crypto.randomUUID(), user_id: user_id ?? null, display_name, order_index: prev.length },
|
||||
]);
|
||||
setInstructorsDirty(true);
|
||||
}, []);
|
||||
|
||||
const removeInstructor = useCallback((key) => {
|
||||
setInstructors((prev) => prev.filter((i) => i._key !== key).map((i, idx) => ({ ...i, order_index: idx })));
|
||||
setInstructorsDirty(true);
|
||||
}, []);
|
||||
|
||||
const removeLinkedInstructor = useCallback((userId) => {
|
||||
setInstructors((prev) => prev.filter((i) => i.user_id !== userId).map((i, idx) => ({ ...i, order_index: idx })));
|
||||
setInstructorsDirty(true);
|
||||
}, []);
|
||||
|
||||
const updateDisplayName = useCallback((key, value) => {
|
||||
setInstructors((prev) => prev.map((i) => i._key === key ? { ...i, display_name: value } : i));
|
||||
setInstructorsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveInstructors = async () => {
|
||||
setInstructorsLoading(true);
|
||||
await syncInstructors(courseId, instructors.map(({ user_id, display_name, order_index }) => ({ user_id, display_name, order_index })));
|
||||
setInstructorsDirty(false);
|
||||
setInstructorsLoading(false);
|
||||
};
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
|
||||
console.log(values)
|
||||
const payload = {
|
||||
...values,
|
||||
objectives: values.objectives?.map((o, i) => ({
|
||||
objective_id: o.objective_id ?? null,
|
||||
text: o.text,
|
||||
order_index: i,
|
||||
})) ?? [],
|
||||
objectives:
|
||||
values.objectives?.map((o, i) => ({
|
||||
objective_id: o.objective_id ?? null,
|
||||
text: o.text, // ✅ consistently "text"
|
||||
order_index: i,
|
||||
})) ?? [],
|
||||
level: values.level || null,
|
||||
course_code: values.course_code || null,
|
||||
updatedBy: user?.user_id ?? null,
|
||||
@@ -131,18 +299,25 @@ export default function EditCourse() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={course ? `Edit: ${course.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Edit Course</h1>
|
||||
<p className="text-sm text-muted-foreground">Update course details.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Update course details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,43 +325,61 @@ export default function EditCourse() {
|
||||
|
||||
{/* ── Basic Info ── */}
|
||||
<SectionCard title="Basic Information">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
|
||||
<Label htmlFor="title">
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="e.g. Introduction to Real Estate"
|
||||
{...register("title")}
|
||||
/>
|
||||
<FieldError message={errors.title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Optional course description"
|
||||
rows={3}
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="course_code">Course Code</Label>
|
||||
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
|
||||
<Input
|
||||
id="course_code"
|
||||
placeholder="e.g. RE-101"
|
||||
{...register("course_code")}
|
||||
/>
|
||||
<FieldError message={errors.course_code?.message} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="order_index">Order</Label>
|
||||
<Input id="order_index" type="number" min={0} {...register("order_index")} />
|
||||
<Input
|
||||
id="order_index"
|
||||
type="number"
|
||||
min={0}
|
||||
{...register("order_index")}
|
||||
/>
|
||||
<FieldError message={errors.order_index?.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Settings ── */}
|
||||
<SectionCard title="Settings">
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Level</Label>
|
||||
<Select
|
||||
value={watch("level") ?? ""}
|
||||
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
|
||||
onValueChange={(val) =>
|
||||
setValue("level", val, { shouldDirty: true })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select level" />
|
||||
@@ -204,7 +397,9 @@ export default function EditCourse() {
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watch("subscription") ?? "free"}
|
||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||
onValueChange={(val) =>
|
||||
setValue("subscription", val, { shouldDirty: true })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select subscription" />
|
||||
@@ -216,9 +411,7 @@ export default function EditCourse() {
|
||||
</Select>
|
||||
<FieldError message={errors.subscription?.message} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Objectives ── */}
|
||||
@@ -232,9 +425,11 @@ export default function EditCourse() {
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Objective ${index + 1}`}
|
||||
{...register(`objectives.${index}.value`)}
|
||||
{...register(`objectives.${index}.text`)} // ✅ was "value", now "text"
|
||||
/>
|
||||
<FieldError
|
||||
message={errors.objectives?.[index]?.text?.message}
|
||||
/>
|
||||
<FieldError message={errors.objectives?.[index]?.text?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -261,6 +456,279 @@ export default function EditCourse() {
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Categories ── */}
|
||||
<SectionCard title="Categories" description="Assign this course to one or more categories for browsing.">
|
||||
|
||||
{/* Selected badges */}
|
||||
{selectedCategoryIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allCategories
|
||||
.filter((c) => selectedCategoryIds.includes(String(c.id)))
|
||||
.map((cat) => (
|
||||
<Badge key={cat.id} variant="secondary" className="gap-1 pr-1">
|
||||
{cat.name}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded-full hover:bg-muted"
|
||||
onClick={() => toggleCategory(String(cat.id))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Picker */}
|
||||
{allCategories.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No categories yet.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => navigate("/admin/courses/categories/add")}
|
||||
>
|
||||
Add one
|
||||
</button>.
|
||||
</p>
|
||||
) : (
|
||||
<Popover open={catOpen} onOpenChange={setCatOpen}>
|
||||
<PopoverTrigger asChild className="w-full">
|
||||
<Button type="button" variant="outline" size="sm" className="w-full justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
{selectedCategoryIds.length > 0
|
||||
? `${selectedCategoryIds.length} selected`
|
||||
: "Select categories"}
|
||||
</span>
|
||||
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search categories…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No categories found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allCategories.map((cat) => {
|
||||
const checked = selectedCategoryIds.includes(String(cat.id));
|
||||
return (
|
||||
<CommandItem
|
||||
key={cat.id}
|
||||
value={cat.name}
|
||||
onSelect={() => toggleCategory(String(cat.id))}
|
||||
className="gap-2"
|
||||
>
|
||||
<Checkbox checked={checked} className="pointer-events-none" />
|
||||
<span className="flex-1 text-sm">{cat.name}</span>
|
||||
{!cat.is_active && (
|
||||
<Badge variant="secondary" className="text-[10px]">Inactive</Badge>
|
||||
)}
|
||||
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!categoriesDirty || categoriesLoading}
|
||||
onClick={handleSaveCategories}
|
||||
>
|
||||
{categoriesLoading && <Spinner className="h-3 w-3 mr-1.5" />}
|
||||
<Save className="h-3 w-3 mr-1.5" />
|
||||
Save Categories
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Product Listing ── */}
|
||||
<SectionCard
|
||||
title="Product Listing"
|
||||
description="Allow learners to purchase this course individually via PayPal."
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label htmlFor="prod_name">Listing Name</Label>
|
||||
<Input
|
||||
id="prod_name"
|
||||
placeholder="e.g. Real Estate Fundamentals"
|
||||
value={productForm.name}
|
||||
onChange={(e) => handleProductChange("name", e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Defaults to course title if left blank.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="prod_price">Price <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="prod_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder="0.00"
|
||||
value={productForm.price}
|
||||
onChange={(e) => handleProductChange("price", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="prod_currency">Currency</Label>
|
||||
<Input
|
||||
id="prod_currency"
|
||||
maxLength={3}
|
||||
placeholder="USD"
|
||||
value={productForm.currency}
|
||||
onChange={(e) => handleProductChange("currency", e.target.value.toUpperCase())}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label htmlFor="prod_access">Access Duration (days)</Label>
|
||||
<Input
|
||||
id="prod_access"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Leave blank for lifetime access"
|
||||
value={productForm.access_days}
|
||||
onChange={(e) => handleProductChange("access_days", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div>
|
||||
<Label>Listed for Purchase</Label>
|
||||
<p className="text-xs text-muted-foreground">Show "Buy this course" button to learners.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={productForm.is_active}
|
||||
onCheckedChange={(v) => handleProductChange("is_active", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
{product && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive border-destructive/50 hover:bg-destructive/5"
|
||||
disabled={productLoading}
|
||||
onClick={handleRemoveProduct}
|
||||
>
|
||||
{productLoading && <Spinner className="h-3 w-3 mr-1.5" />}
|
||||
Remove Listing
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
disabled={!productDirty || productLoading || !productForm.price}
|
||||
onClick={handleSaveProduct}
|
||||
>
|
||||
{productLoading && <Spinner className="h-3 w-3 mr-1.5" />}
|
||||
<Save className="h-3 w-3 mr-1.5" />
|
||||
{product ? "Update Listing" : "Create Listing"}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Course Instructors ── */}
|
||||
<SectionCard
|
||||
title="Course Instructors"
|
||||
description="These names appear on the certificate. Link a staff or admin account for audit trail, or enter a display name only for external speakers."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{instructors.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No instructors added yet.</p>
|
||||
)}
|
||||
{instructors.map((inst) => (
|
||||
<div key={inst._key} className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
value={inst.display_name}
|
||||
placeholder="Display name on certificate"
|
||||
onChange={(e) => updateDisplayName(inst._key, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
{inst.user_id && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 bg-blue-50 text-blue-700 border-blue-200">
|
||||
Linked
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => removeInstructor(inst._key)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CourseInstructorPicker
|
||||
added={instructors}
|
||||
onAdd={addInstructor}
|
||||
onToggleLinked={removeLinkedInstructor}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!instructorsDirty || instructorsLoading}
|
||||
onClick={handleSaveInstructors}
|
||||
>
|
||||
{instructorsLoading && <Spinner className="h-3 w-3 mr-1.5" />}
|
||||
<Save className="h-3 w-3 mr-1.5" />
|
||||
Save Instructors
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Certificate of Completion ── */}
|
||||
<SectionCard
|
||||
title="Certificate of Completion"
|
||||
description="Automatically awarded to learners who pass this course's assessment. Mandatory for all courses."
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<CertBadgeIcon className="size-20 shrink-0" />
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">Certificate of Completion</span>
|
||||
<Badge className="bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
|
||||
<BadgeCheck className="size-3" /> Mandatory
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
This badge is issued automatically when a learner passes the course assessment.
|
||||
It appears on their profile under <strong>Certificates</strong> and in the course
|
||||
content listing for all enrolled users.
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5 mt-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Label:</span> Certificate of Completion
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Type:</span> Milestone achievement
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button
|
||||
@@ -276,7 +744,6 @@ export default function EditCourse() {
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, ClipboardList, NotebookPen, CheckCircle2, Circle } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||
<span className="text-sm font-medium">{children ?? <span className="italic text-muted-foreground">—</span>}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
{title && (
|
||||
<>
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TYPE_LABELS = {
|
||||
multiple_choice: "Multiple Choice",
|
||||
multi_select: "Multi Select",
|
||||
true_false: "True / False",
|
||||
};
|
||||
|
||||
function QuestionView({ question, index }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-xs font-semibold text-muted-foreground mt-0.5">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm font-medium leading-snug">
|
||||
{question.question?.trim() || <span className="italic text-muted-foreground">Untitled</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{TYPE_LABELS[question.type] ?? question.type}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5 pl-8">
|
||||
{(question.options ?? []).map((opt, oi) => (
|
||||
<li key={opt.option_id ?? oi} className="flex items-center gap-2 text-sm">
|
||||
{opt.is_correct ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-muted-foreground/40 shrink-0" />
|
||||
)}
|
||||
<span className={opt.is_correct ? "font-medium text-green-700 dark:text-green-400" : "text-muted-foreground"}>
|
||||
{opt.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full rounded-lg" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ViewAssessment() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchAssessment, assessment, loading } = useCourses();
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssessment(courseId);
|
||||
}, [courseId]);
|
||||
|
||||
const questions = assessment?.questions ?? [];
|
||||
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<PageMeta title="View Assessment - STARR" />
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div
|
||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||
style={{ top: "var(--navbar-h)" }}
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
||||
View Assessment
|
||||
</h1>
|
||||
{assessment && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}
|
||||
>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Modify Assessment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||
{loading && !assessment ? (
|
||||
<LoadingSkeleton />
|
||||
) : !assessment ? (
|
||||
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
|
||||
<ClipboardList className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No assessment has been created for this course yet.</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}
|
||||
>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Create Assessment
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Settings ── */}
|
||||
<SectionCard title="Settings">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Title">{assessment.title || "Course Assessment"}</InfoRow>
|
||||
<InfoRow label="Required">
|
||||
<Badge variant={assessment.is_required ? "default" : "secondary"} className="mt-0.5">
|
||||
{assessment.is_required ? "Required" : "Optional"}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label="Passing Score">{assessment.passing_score ?? 70}%</InfoRow>
|
||||
<InfoRow label="Time Limit">
|
||||
{assessment.time_limit_minutes ? `${assessment.time_limit_minutes} mins` : "No limit"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
|
||||
<InfoRow label="Max Shown per Attempt">
|
||||
{assessment.max_questions
|
||||
? `${assessment.max_questions} (random)`
|
||||
: `All (${questions.length})`}
|
||||
</InfoRow>
|
||||
<InfoRow label="Total Points">{totalPoints}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Questions ── */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">
|
||||
Questions
|
||||
</p>
|
||||
{questions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-card p-10 flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">No questions added yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
questions.map((q, i) => (
|
||||
<QuestionView key={q.question_id ?? i} question={q} index={i} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft, House, Pencil, Clock, BookOpen, Layers,
|
||||
BadgeCheck, Tag, Star, Lock, ListChecks,
|
||||
BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -74,6 +76,7 @@ export default function ViewCourse() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={course ? `${course.title} - STARR` : undefined} description={course?.description} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
@@ -218,6 +221,11 @@ export default function ViewCourse() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Reading Progress ── */}
|
||||
<SectionCard icon={BarChart2} title="Reading Progress">
|
||||
<CourseReadingProgressList courseId={courseId} />
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Audit ── */}
|
||||
<SectionCard icon={BadgeCheck} title="Audit">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -57,6 +58,7 @@ export default function AddLesson() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={unit ? `Add Lesson – ${unit.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -41,6 +42,7 @@ export default function ArchivedLessonsList() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={unit ? `Archived Lessons – ${unit.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -74,6 +75,7 @@ export default function EditLesson() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={lessonTitle ? `Edit: ${lessonTitle} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeft, Save, Pencil, Monitor, Eye, EyeOff, X } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
|
||||
@@ -91,6 +92,7 @@ export default function LessonPageBuilder() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<PageMeta title={lesson ? `Page Builder – ${lesson.title} - STARR` : undefined} />
|
||||
|
||||
{/* ── Sticky builder header ── */}
|
||||
<div
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -40,6 +41,7 @@ export default function LessonsList() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={unit ? `Lessons – ${unit.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { House, Pencil, ArrowLeft, Clock, ListChecks, FileText } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -44,6 +45,7 @@ export default function ViewLesson() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
<div className="w-full max-w-2xl space-y-6">
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
|
||||
@@ -24,6 +25,7 @@ export default function ViewLessonPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<PageMeta title={lesson ? `${lesson.title} – Page - STARR` : undefined} />
|
||||
|
||||
{/* Sticky header */}
|
||||
<div
|
||||
@@ -41,7 +43,7 @@ export default function ViewLessonPage() {
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page-builder`)}
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
|
||||
>
|
||||
Edit Page
|
||||
</Button>
|
||||
@@ -71,7 +73,7 @@ export default function ViewLessonPage() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page-builder`)}
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
|
||||
>
|
||||
Go to Page Builder
|
||||
</Button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ArrowLeft, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -48,6 +49,7 @@ export default function AddUnit() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={course ? `Add Unit – ${course.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import ArchivedUnitsTable from "../../../components/courses/ArchivedUnitsTable";
|
||||
@@ -39,6 +40,7 @@ export default function ArchivedUnitsList() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={course ? `Archived Units – ${course.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ArrowLeft, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -60,6 +61,7 @@ export default function EditUnit() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={unitTitle ? `Edit: ${unitTitle} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown } from
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -73,7 +74,7 @@ const TYPE_LABEL = {
|
||||
true_false: "TF",
|
||||
};
|
||||
|
||||
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, errors }) {
|
||||
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
@@ -86,7 +87,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div ref={navContainerRef} className="flex-1 overflow-y-auto py-2">
|
||||
{questions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-8 px-3">
|
||||
No questions yet.
|
||||
@@ -194,6 +195,7 @@ export default function UnitQuiz() {
|
||||
|
||||
const questionRefs = useRef([]);
|
||||
const navItemRefs = useRef([]);
|
||||
const navContainerRef = useRef(null);
|
||||
const headerRef = useRef(null);
|
||||
|
||||
const breadcrumbItems = [
|
||||
@@ -244,10 +246,20 @@ export default function UnitQuiz() {
|
||||
|
||||
// ── Scroll active nav item into view ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
navItemRefs.current[activeIndex]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
const item = navItemRefs.current[activeIndex];
|
||||
const container = navContainerRef.current;
|
||||
if (!item || !container) return;
|
||||
|
||||
const itemTop = item.offsetTop;
|
||||
const itemBottom = itemTop + item.offsetHeight;
|
||||
const viewTop = container.scrollTop;
|
||||
const viewBottom = viewTop + container.clientHeight;
|
||||
|
||||
if (itemTop < viewTop) {
|
||||
container.scrollTop = itemTop;
|
||||
} else if (itemBottom > viewBottom) {
|
||||
container.scrollTop = itemBottom - container.clientHeight;
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
// ── IntersectionObserver — highlight nav as user scrolls ──────────────────
|
||||
@@ -364,6 +376,7 @@ export default function UnitQuiz() {
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<PageMeta title={unit ? `${unit.title} – Quiz - STARR` : undefined} />
|
||||
|
||||
{/* ── Sticky header ── */}
|
||||
<div
|
||||
@@ -411,6 +424,7 @@ export default function UnitQuiz() {
|
||||
onJump={jumpTo}
|
||||
onMove={moveQuestion}
|
||||
navItemRefs={navItemRefs}
|
||||
navContainerRef={navContainerRef}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import UnitsTable from "../../../components/courses/UnitsTable";
|
||||
@@ -38,6 +39,7 @@ export default function UnitsList() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={course ? `Units – ${course.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { House, Pencil, ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
@@ -31,6 +32,7 @@ export default function ViewUnit() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={unit ? `${unit.title} - STARR` : undefined} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, HelpCircle, NotebookPen, CheckCircle2, Circle } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||
<span className="text-sm font-medium">{children ?? <span className="italic text-muted-foreground">—</span>}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
{title && (
|
||||
<>
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TYPE_LABELS = {
|
||||
multiple_choice: "Multiple Choice",
|
||||
multi_select: "Multi Select",
|
||||
true_false: "True / False",
|
||||
};
|
||||
|
||||
function QuestionView({ question, index }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-xs font-semibold text-muted-foreground mt-0.5">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm font-medium leading-snug">
|
||||
{question.question?.trim() || <span className="italic text-muted-foreground">Untitled</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{TYPE_LABELS[question.type] ?? question.type}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5 pl-8">
|
||||
{(question.options ?? []).map((opt, oi) => (
|
||||
<li key={opt.option_id ?? oi} className="flex items-center gap-2 text-sm">
|
||||
{opt.is_correct ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-muted-foreground/40 shrink-0" />
|
||||
)}
|
||||
<span className={opt.is_correct ? "font-medium text-green-700 dark:text-green-400" : "text-muted-foreground"}>
|
||||
{opt.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full rounded-lg" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ViewUnitQuiz() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchQuiz, quiz, loading } = useCourses();
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuiz(courseId, unitId);
|
||||
}, [courseId, unitId]);
|
||||
|
||||
const questions = quiz?.questions ?? [];
|
||||
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||||
<PageMeta title={quiz ? `${quiz.title ?? 'Quiz'} – View - STARR` : undefined} />
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div
|
||||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||
style={{ top: "var(--navbar-h)" }}
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||
<HelpCircle className="h-5 w-5 text-muted-foreground" />
|
||||
View Quiz
|
||||
</h1>
|
||||
{quiz && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}
|
||||
>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Modify Quiz
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||
{loading && !quiz ? (
|
||||
<LoadingSkeleton />
|
||||
) : !quiz ? (
|
||||
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
|
||||
<HelpCircle className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No quiz has been created for this unit yet.</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}
|
||||
>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Create Quiz
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Settings ── */}
|
||||
<SectionCard title="Settings">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Title">{quiz.title || "Unit Quiz"}</InfoRow>
|
||||
<InfoRow label="Required">
|
||||
<Badge variant={quiz.is_required ? "default" : "secondary"} className="mt-0.5">
|
||||
{quiz.is_required ? "Required" : "Optional"}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label="Passing Score">{quiz.passing_score ?? 70}%</InfoRow>
|
||||
<InfoRow label="Max Shown per Attempt">
|
||||
{quiz.max_questions
|
||||
? `${quiz.max_questions} (random)`
|
||||
: `All (${questions.length})`}
|
||||
</InfoRow>
|
||||
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
|
||||
<InfoRow label="Total Points">{totalPoints}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Questions ── */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">
|
||||
Questions
|
||||
</p>
|
||||
{questions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-card p-10 flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">No questions added yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
questions.map((q, i) => (
|
||||
<QuestionView key={q.question_id ?? i} question={q} index={i} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
@@ -18,7 +18,7 @@ import DeadlinePicker from '@/components/generic/DeadlinePicker';
|
||||
export default function CreateTask() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId } = useParams();
|
||||
const { createTask, loading } = useAdminTask();
|
||||
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
@@ -27,6 +27,15 @@ export default function CreateTask() {
|
||||
requirements: [],
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [lessons, setLessons] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||
fetchUnitsFlat().then((d) => d && setUnits(d));
|
||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||
}, []);
|
||||
|
||||
const validate = () => {
|
||||
const e = {};
|
||||
@@ -111,6 +120,9 @@ export default function CreateTask() {
|
||||
<RequirementBuilder
|
||||
value={form.requirements}
|
||||
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
|
||||
courses={courses}
|
||||
units={units}
|
||||
lessons={lessons}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -24,10 +24,13 @@ const STATUS_OPTIONS = [
|
||||
export default function EditTask() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId, taskId } = useParams();
|
||||
const { fetchTask, updateTask, loading } = useAdminTask();
|
||||
const { fetchTask, updateTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [lessons, setLessons] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTask(taskListId, taskId).then((data) => {
|
||||
@@ -42,6 +45,9 @@ export default function EditTask() {
|
||||
requirements: data.requirements ?? [],
|
||||
});
|
||||
});
|
||||
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||
fetchUnitsFlat().then((d) => d && setUnits(d));
|
||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||
}, [taskListId, taskId]);
|
||||
|
||||
const validate = () => {
|
||||
@@ -148,6 +154,9 @@ export default function EditTask() {
|
||||
<RequirementBuilder
|
||||
value={form.requirements}
|
||||
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
|
||||
courses={courses}
|
||||
units={units}
|
||||
lessons={lessons}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText } from 'lucide-react';
|
||||
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
// ─── Requirement type config ──────────────────────────────────────────────────
|
||||
const REQUIREMENT_TYPES = [
|
||||
@@ -26,9 +30,63 @@ const FILE_TYPE_OPTIONS = [
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'jpg', label: 'JPG' },
|
||||
{ value: 'mp4', label: 'MP4' },
|
||||
{ value: 'mp3', label: 'MP3' },
|
||||
{ value: 'zip', label: 'ZIP' },
|
||||
];
|
||||
|
||||
// ─── Searchable content picker (Popover + Command + ScrollArea) ───────────────
|
||||
// renderItem — optional custom JSX per item (defaults to o[labelKey])
|
||||
// searchKey — optional key whose value cmdk uses for filtering (defaults to labelKey)
|
||||
function ContentPicker({ value, options, idKey, labelKey, searchKey, placeholder = 'Select…', onSelect, renderItem, listHeight = 'h-48' }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((o) => String(o[idKey]) === String(value));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-full justify-between text-sm font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selected
|
||||
? selected[labelKey]
|
||||
: <span className="text-muted-foreground">{placeholder}</span>}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search…" />
|
||||
<CommandList className="max-h-none overflow-visible">
|
||||
<ScrollArea className={listHeight}>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((o) => (
|
||||
<CommandItem
|
||||
key={o[idKey]}
|
||||
value={searchKey ? o[searchKey] : o[labelKey]}
|
||||
onSelect={() => {
|
||||
onSelect(o);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{renderItem ? renderItem(o) : o[labelKey]}
|
||||
<Check className={cn('ml-auto h-4 w-4 shrink-0', String(value) === String(o[idKey]) ? 'opacity-100' : 'opacity-0')} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty requirement factory ────────────────────────────────────────────────
|
||||
function createRequirement(type = 'visit_link') {
|
||||
return {
|
||||
@@ -194,77 +252,61 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
||||
{item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
|
||||
</Label>
|
||||
|
||||
{/* Reference selector */}
|
||||
{/* Reference picker */}
|
||||
{item.type === 'read_course' && (
|
||||
<Select
|
||||
<ContentPicker
|
||||
value={item.reference_id}
|
||||
onValueChange={(v) => {
|
||||
const course = courses.find((c) => c.course_id === v);
|
||||
updateItem(item._key, {
|
||||
reference_id: v,
|
||||
reference_label: course?.title ?? '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select a course" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{courses.map((c) => (
|
||||
<SelectItem key={c.course_id} value={c.course_id}>
|
||||
{c.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
options={courses}
|
||||
idKey="uuid"
|
||||
labelKey="title"
|
||||
placeholder="Select a course"
|
||||
onSelect={(c) => updateItem(item._key, { reference_id: c.uuid, reference_label: c.title })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.type === 'read_unit' && (
|
||||
<Select
|
||||
<ContentPicker
|
||||
value={item.reference_id}
|
||||
onValueChange={(v) => {
|
||||
const unit = units.find((u) => u.unit_id === v);
|
||||
updateItem(item._key, {
|
||||
reference_id: v,
|
||||
reference_label: unit?.title ?? '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select a unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map((u) => (
|
||||
<SelectItem key={u.unit_id} value={u.unit_id}>
|
||||
{u.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
options={units}
|
||||
idKey="uuid"
|
||||
labelKey="title"
|
||||
searchKey="_search"
|
||||
placeholder="Select a unit"
|
||||
renderItem={(u) => (
|
||||
<div className="flex flex-col gap-0.5 py-0.5 min-w-0">
|
||||
<span className="text-xs text-muted-foreground leading-tight truncate">
|
||||
{u.course_title} · Unit {u.order_index + 1}
|
||||
</span>
|
||||
<span className="text-sm leading-tight truncate">{u.title}</span>
|
||||
</div>
|
||||
)}
|
||||
onSelect={(u) => updateItem(item._key, { reference_id: u.uuid, reference_label: u.title })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.type === 'read_lesson' && (
|
||||
<Select
|
||||
<ContentPicker
|
||||
value={item.reference_id}
|
||||
onValueChange={(v) => {
|
||||
const lesson = lessons.find((l) => l.lesson_id === v);
|
||||
updateItem(item._key, {
|
||||
reference_id: v,
|
||||
reference_label: lesson?.title ?? '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select a lesson" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lessons.map((l) => (
|
||||
<SelectItem key={l.lesson_id} value={l.lesson_id}>
|
||||
{l.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
options={lessons}
|
||||
idKey="uuid"
|
||||
labelKey="title"
|
||||
searchKey="_search"
|
||||
placeholder="Select a lesson"
|
||||
listHeight="h-64"
|
||||
renderItem={(l) => (
|
||||
<div className="flex flex-col gap-0.5 py-0.5 min-w-0">
|
||||
<span className="text-xs text-muted-foreground leading-tight truncate">
|
||||
{l.course_title}
|
||||
<span className="mx-1 opacity-50">›</span>
|
||||
Unit {l.unit_order + 1}
|
||||
<span className="mx-1 opacity-50">›</span>
|
||||
Lesson {l.order_index + 1}
|
||||
</span>
|
||||
<span className="text-sm leading-tight truncate">{l.title}</span>
|
||||
</div>
|
||||
)}
|
||||
onSelect={(l) => updateItem(item._key, { reference_id: l.uuid, reference_label: l.title })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : TaskCompletions.jsx
|
||||
* Type : Page
|
||||
* Description : Admin page — lists all completions for a specific task.
|
||||
* Route: /admin/taskList/:taskListId/tasks/:taskId/completions
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
|
||||
import DataTable from '@/components/generic/Table/DataTable';
|
||||
import { FilterSheet } from '@/components/generic/Sheet/FilterSheet';
|
||||
import { ArchiveDialog } from '@/components/generic/Dialogs/ArchiveDialog';
|
||||
import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { House, NotebookPen, Users, Paperclip } from 'lucide-react';
|
||||
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
||||
import { formatDate } from '@/utils/table.util';
|
||||
import { getTimestamp } from '@/utils/timestamp.util';
|
||||
|
||||
import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task_completion/columns.config';
|
||||
import { buildToolbarActions } from '@/modules/admin/config/task_list/task_completion/toolbar.config';
|
||||
import { buildSelectionActions } from '@/modules/admin/config/task_list/task_completion/selection.config';
|
||||
import { buildRowActions } from '@/modules/admin/config/task_list/task_completion/rowActions.config';
|
||||
|
||||
// ─── Stat card ────────────────────────────────────────────────────────────────
|
||||
const StatCard = ({ label, value, icon: Icon, loading }) => (
|
||||
<div className="bg-muted rounded-lg px-3 py-2">
|
||||
<span className="block text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||
{Icon && <Icon className="size-3.5 text-muted-foreground" />}
|
||||
{loading ? <Skeleton className="h-4 w-10 inline-block" /> : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
export default function TaskCompletions() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId, taskId } = useParams();
|
||||
|
||||
const {
|
||||
task, taskList,
|
||||
completions, completionPagination, setCompletionPagination, completionLoading,
|
||||
completionAttributes,
|
||||
fetchTask, fetchTaskList,
|
||||
fetchCompletions,
|
||||
archiveCompletion, restoreCompletion,
|
||||
bulkArchiveCompletions, bulkRestoreCompletions,
|
||||
} = useAdminTask();
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
|
||||
const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
|
||||
});
|
||||
|
||||
// ── Initial fetch ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
fetchTaskList(taskListId);
|
||||
fetchTask(taskListId, taskId);
|
||||
fetchCompletions(taskListId, taskId, { page: 1, limit: 10 });
|
||||
}, [taskListId, taskId]);
|
||||
|
||||
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
|
||||
|
||||
// ── Fetch handler ─────────────────────────────────────────────────────────
|
||||
const handleFetch = useCallback((params) => {
|
||||
return fetchCompletions(taskListId, taskId, params);
|
||||
}, [fetchCompletions, taskListId, taskId]);
|
||||
|
||||
// ── Toggle archived ───────────────────────────────────────────────────────
|
||||
const handleToggleArchived = () => {
|
||||
const next = !showArchived;
|
||||
setShowArchived(next);
|
||||
fetchCompletions(taskListId, taskId, {
|
||||
page: 1,
|
||||
limit: completionPagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
});
|
||||
};
|
||||
|
||||
// ── After mutation ────────────────────────────────────────────────────────
|
||||
const afterMutation = () => {
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchCompletions(taskListId, taskId, {
|
||||
page: 1,
|
||||
limit: completionPagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
});
|
||||
};
|
||||
|
||||
// ── Derived stats ─────────────────────────────────────────────────────────
|
||||
const totalRecords = completionPagination?.totalRecords ?? 0;
|
||||
const totalFiles = completions.reduce((acc, c) => acc + (c.files?.length ?? 0), 0);
|
||||
const uniqueUsers = new Set(completions.map((c) => c.user_id)).size;
|
||||
const latestDate = completions[0]?.submitted_at
|
||||
? formatDate(completions[0].submitted_at)
|
||||
: '—';
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────
|
||||
const exportConfig = useMemo(() => ({
|
||||
allData: completions,
|
||||
attributes: completionAttributes,
|
||||
filename: `${getTimestamp()}_Completions`,
|
||||
sheetName: 'Completions',
|
||||
}), [completions, completionAttributes]);
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
navigate,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
showArchived,
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchCompletions,
|
||||
taskListId,
|
||||
taskId,
|
||||
pagination: completionPagination,
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onToggleArchived: handleToggleArchived,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onBulkArchive: (ids) => setBulkArchiveIds(ids),
|
||||
onBulkRestore: (ids) => setBulkRestoreIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(completionAttributes, rowActions),
|
||||
[completionAttributes, rowActions]
|
||||
);
|
||||
|
||||
// ── Breadcrumbs ───────────────────────────────────────────────────────────
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', icon: <House className="size-4" />, to: '/admin' },
|
||||
{ label: 'Task Lists', to: '/admin/taskList' },
|
||||
{ label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks` },
|
||||
{ label: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` },
|
||||
{ label: 'Completions' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
|
||||
{/* ── Breadcrumb ────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={breadcrumbs} />
|
||||
</div>
|
||||
|
||||
{/* ── Detail card ───────────────────────────────────────────────── */}
|
||||
<div className="bg-card border rounded-xl p-5 flex flex-col gap-4 w-full mb-6">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
{task
|
||||
? <h1 className="text-lg font-medium leading-none">{task.name}</h1>
|
||||
: <Skeleton className="h-5 w-48" />
|
||||
}
|
||||
{task
|
||||
? <p className="text-sm text-muted-foreground mt-1">
|
||||
{taskList?.name ?? '—'}
|
||||
{task.deadline ? ` · Due ${formatDate(task.deadline)}` : ''}
|
||||
</p>
|
||||
: <Skeleton className="h-4 w-72 mt-1" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard label="Completions" value={totalRecords} icon={NotebookPen} loading={completionLoading} />
|
||||
<StatCard label="Unique users" value={uniqueUsers} icon={Users} loading={completionLoading} />
|
||||
<StatCard label="Files" value={totalFiles} icon={Paperclip} loading={completionLoading} />
|
||||
<StatCard label="Latest" value={latestDate} loading={completionLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── DataTable ─────────────────────────────────────────────────── */}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={completions}
|
||||
attributes={completionAttributes}
|
||||
pagination={completionPagination}
|
||||
setPagination={setCompletionPagination}
|
||||
loading={completionLoading}
|
||||
onFetch={handleFetch}
|
||||
onFetchFilterData={() => Promise.resolve([])}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
columnPinning={columnPinning}
|
||||
onRefsReady={handleRefsReady}
|
||||
recordLabel="completion"
|
||||
emptyMessage="No completions yet."
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ── Single archive ────────────────────────────────────────────── */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||
entity={archiveTarget}
|
||||
entityLabel="Completion"
|
||||
getName={(r) => r?.user?.name ?? 'this completion'}
|
||||
onArchive={(entity) => archiveCompletion(taskListId, taskId, entity?.completion_id)}
|
||||
loading={completionLoading}
|
||||
onSuccess={afterMutation}
|
||||
/>
|
||||
|
||||
{/* ── Single restore ────────────────────────────────────────────── */}
|
||||
<RestoreDialog
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||
entity={restoreTarget}
|
||||
entityLabel="Completion"
|
||||
getName={(r) => r?.user?.name ?? 'this completion'}
|
||||
onRestore={(entity) => restoreCompletion(taskListId, taskId, entity?.completion_id)}
|
||||
loading={completionLoading}
|
||||
onSuccess={afterMutation}
|
||||
/>
|
||||
|
||||
{/* ── Bulk archive ──────────────────────────────────────────────── */}
|
||||
<ArchiveDialog
|
||||
open={!!bulkArchiveIds}
|
||||
onOpenChange={(v) => !v && setBulkArchiveIds(null)}
|
||||
ids={bulkArchiveIds ?? []}
|
||||
entityLabel="Completion"
|
||||
onArchive={({ ids }) => bulkArchiveCompletions(taskListId, taskId, ids)}
|
||||
loading={completionLoading}
|
||||
onSuccess={afterMutation}
|
||||
/>
|
||||
|
||||
{/* ── Bulk restore ──────────────────────────────────────────────── */}
|
||||
<RestoreDialog
|
||||
open={!!bulkRestoreIds}
|
||||
onOpenChange={(v) => !v && setBulkRestoreIds(null)}
|
||||
ids={bulkRestoreIds ?? []}
|
||||
entityLabel="Completion"
|
||||
onRestore={({ ids }) => bulkRestoreCompletions(taskListId, taskId, ids)}
|
||||
loading={completionLoading}
|
||||
onSuccess={afterMutation}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : ViewTaskCompletion.jsx
|
||||
* Type : Page
|
||||
* Description : Admin page — views a single task completion in full detail.
|
||||
* Left: submitter info + note + attached files (with open via media route).
|
||||
* Right: task context card + submission history timeline.
|
||||
* Route: /admin/taskList/:taskListId/tasks/:taskId/completions/:completionId/view
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
|
||||
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
House, Paperclip, FileText, FileImage,
|
||||
FileVideo, FileAudio, File, ArrowLeft,
|
||||
Clock, CalendarDays, ExternalLink, NotebookPen,
|
||||
} from 'lucide-react';
|
||||
import { formatDate } from '@/utils/table.util';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const getInitials = (name = '') =>
|
||||
name.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (!bytes) return null;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
// ─── File icon by mime type ───────────────────────────────────────────────────
|
||||
const FileIcon = ({ mimeType, className = 'size-5' }) => {
|
||||
if (!mimeType) return <File className={className} />;
|
||||
if (mimeType.startsWith('image/')) return <FileImage className={`${className} text-blue-500`} />;
|
||||
if (mimeType.startsWith('video/')) return <FileVideo className={`${className} text-purple-500`} />;
|
||||
if (mimeType.startsWith('audio/')) return <FileAudio className={`${className} text-green-500`} />;
|
||||
if (mimeType === 'application/pdf') return <FileText className={`${className} text-red-500`} />;
|
||||
return <File className={`${className} text-muted-foreground`} />;
|
||||
};
|
||||
|
||||
// ─── Timeline dot ─────────────────────────────────────────────────────────────
|
||||
const TimelineDot = ({ active }) => (
|
||||
<span
|
||||
className={`mt-1 size-2.5 rounded-full shrink-0 ring-2 ring-offset-1 ${
|
||||
active
|
||||
? 'bg-blue-500 ring-blue-300 dark:ring-blue-700'
|
||||
: 'bg-border ring-transparent'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
|
||||
// ─── Section card wrapper ─────────────────────────────────────────────────────
|
||||
const Card = ({ children, className = '' }) => (
|
||||
<div className={`bg-card border rounded-xl p-5 flex flex-col gap-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CardTitle = ({ icon: Icon, children }) => (
|
||||
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-muted-foreground" />}
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
export default function ViewTaskCompletion() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId, taskId, completionId } = useParams();
|
||||
|
||||
const {
|
||||
task, taskList,
|
||||
completion, completionLoading,
|
||||
completions,
|
||||
fetchTask, fetchTaskList,
|
||||
fetchCompletion,
|
||||
fetchCompletionsByUser,
|
||||
} = useAdminTask();
|
||||
|
||||
// ── Fetch on mount ────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
fetchTaskList(taskListId);
|
||||
fetchTask(taskListId, taskId);
|
||||
fetchCompletion(taskListId, taskId, completionId);
|
||||
}, [taskListId, taskId, completionId]);
|
||||
|
||||
// ── Once we know the user, fetch their full history for the timeline ──────
|
||||
useEffect(() => {
|
||||
if (completion?.user_id) {
|
||||
fetchCompletionsByUser(taskListId, taskId, completion.user_id);
|
||||
}
|
||||
}, [completion?.user_id]);
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', icon: <House className="size-4" />, to: '/admin' },
|
||||
{ label: 'Task Lists', to: '/admin/taskList' },
|
||||
{ label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks` },
|
||||
{ label: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` },
|
||||
{ label: 'Completions', to: `/admin/taskList/${taskListId}/tasks/${taskId}/completions` },
|
||||
{ label: 'View' },
|
||||
];
|
||||
|
||||
const isLoading = completionLoading && !completion;
|
||||
|
||||
return (
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
|
||||
{/* ── Breadcrumb + back ─────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 my-6 w-full">
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<AppBreadcrumb items={breadcrumbs} />
|
||||
</div>
|
||||
|
||||
{/* ── Two-column layout ─────────────────────────────────────────── */}
|
||||
<div className="grid lg:grid-cols-[1fr_300px] gap-5 w-full items-start">
|
||||
|
||||
{/* ── LEFT ──────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Submitter + note */}
|
||||
<Card>
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3.5 w-44" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10">
|
||||
<AvatarFallback className="text-sm bg-blue-100 text-blue-700">
|
||||
{getInitials(completion?.user?.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-medium">
|
||||
{completion?.user?.name ?? '—'}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{completion?.user?.email ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-4 w-32" />
|
||||
) : (
|
||||
<div className="flex flex-col items-end text-right shrink-0">
|
||||
<span className="text-xs text-muted-foreground">Submitted</span>
|
||||
<span className="text-sm font-medium mt-0.5">
|
||||
{completion?.submitted_at
|
||||
? formatDate(completion.submitted_at)
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Note */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CardTitle icon={FileText}>Note</CardTitle>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
) : completion?.note ? (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{completion.note}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No note provided.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Files */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle icon={Paperclip}>
|
||||
Attached files
|
||||
</CardTitle>
|
||||
{completion?.files?.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{completion.files.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<Skeleton className="h-3.5 w-40" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16 rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !completion?.files?.length ? (
|
||||
<p className="text-sm text-muted-foreground italic">No files attached.</p>
|
||||
) : (
|
||||
<ScrollArea className="max-h-72">
|
||||
<div className="flex flex-col gap-2">
|
||||
{completion.files.map((f) => (
|
||||
<div
|
||||
key={f.file_id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-muted"
|
||||
>
|
||||
<FileIcon mimeType={f.mime_type} />
|
||||
<div className="flex flex-col leading-tight min-w-0 flex-1">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{f.file_name}
|
||||
</span>
|
||||
{f.file_size && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(f.file_size)}
|
||||
{f.mime_type ? ` · ${f.mime_type}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => window.open(f.file_url, '_blank')}
|
||||
>
|
||||
<ExternalLink className="size-3.5 mr-1" />
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT ─────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-5 lg:sticky lg:top-24 lg:self-start">
|
||||
|
||||
{/* Task context */}
|
||||
<Card>
|
||||
<CardTitle icon={NotebookPen}>Task</CardTitle>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<span className="font-medium">{task?.name ?? '—'}</span>
|
||||
{task?.deadline && (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<CalendarDays className="size-3.5" />
|
||||
Due {formatDate(task.deadline)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<FileText className="size-3.5" />
|
||||
{taskList?.name ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Submission history timeline */}
|
||||
<Card>
|
||||
<CardTitle icon={Clock}>Submission history</CardTitle>
|
||||
{completionLoading && !completions.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<Skeleton className="size-2.5 rounded-full mt-1 shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !completions.length ? (
|
||||
<p className="text-sm text-muted-foreground italic">No history yet.</p>
|
||||
) : (
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="flex flex-col gap-3">
|
||||
{completions.map((c, idx) => {
|
||||
const isCurrent = c.completion_id === completionId;
|
||||
return (
|
||||
<div
|
||||
key={c.completion_id}
|
||||
className={`flex items-start gap-3 cursor-pointer rounded-lg px-2 py-1.5 transition-colors ${
|
||||
isCurrent
|
||||
? 'bg-muted'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
navigate(
|
||||
`/admin/taskList/${taskListId}/tasks/${taskId}/completions/${c.completion_id}/view`
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TimelineDot active={isCurrent} />
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className={`text-sm ${isCurrent ? 'font-medium' : 'text-muted-foreground'}`}>
|
||||
{idx === 0 ? 'Latest · ' : ''}{formatDate(c.submitted_at)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground mt-0.5">
|
||||
{c.files?.length ?? 0} {(c.files?.length ?? 0) === 1 ? 'file' : 'files'}
|
||||
{c.note ? ' · with note' : ' · no note'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House, ChevronsUpDown, Check, X, BookOpen } from "lucide-react";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Command, CommandEmpty, CommandGroup,
|
||||
CommandInput, CommandItem, CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
tier: z.enum(["premium", "exclusive"]),
|
||||
label: z.string().min(1, "Label is required."),
|
||||
duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."),
|
||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||
currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"),
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{title && (
|
||||
<div className="pb-1 border-b">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Course Multi-Select ──────────────────────────────────────────────────────
|
||||
|
||||
function CourseMultiSelect({ courses, selected, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedSet = new Set(selected.map(String));
|
||||
const selectedList = courses.filter((c) => selectedSet.has(String(c.course_id)));
|
||||
|
||||
const toggle = (id) => {
|
||||
const sid = String(id);
|
||||
onChange(
|
||||
selectedSet.has(sid)
|
||||
? selected.filter((x) => String(x) !== sid)
|
||||
: [...selected, sid]
|
||||
);
|
||||
};
|
||||
|
||||
const remove = (id) => onChange(selected.filter((x) => String(x) !== String(id)));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{selected.length === 0
|
||||
? <span className="text-muted-foreground">Select courses…</span>
|
||||
: <span>{selected.length} course{selected.length !== 1 ? "s" : ""} selected</span>
|
||||
}
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search courses…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<BookOpen className="size-6 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No courses found.</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{courses.map((course) => {
|
||||
const checked = selectedSet.has(String(course.course_id));
|
||||
return (
|
||||
<CommandItem
|
||||
key={course.course_id}
|
||||
value={`${course.title} ${course.course_code ?? ""}`}
|
||||
onSelect={() => toggle(String(course.course_id))}
|
||||
className="gap-2"
|
||||
>
|
||||
<div className={cn(
|
||||
"flex size-4 items-center justify-center rounded-sm border border-primary shrink-0",
|
||||
checked ? "bg-primary text-primary-foreground" : "opacity-50"
|
||||
)}>
|
||||
{checked && <Check className="size-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{course.title}</p>
|
||||
{course.level && (
|
||||
<p className="text-xs text-muted-foreground capitalize">{course.level}</p>
|
||||
)}
|
||||
</div>
|
||||
{course.course_code && (
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">{course.course_code}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Selected chips */}
|
||||
{selectedList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedList.map((course) => (
|
||||
<Badge key={course.course_id} variant="secondary" className="gap-1 pr-1">
|
||||
<span className="text-xs max-w-[160px] truncate">{course.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(course.course_id)}
|
||||
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AddPlan() {
|
||||
const navigate = useNavigate();
|
||||
const { createPlan, syncPlanCourses, loading } = useTiers();
|
||||
const { fetchCourses, courses } = useCourses();
|
||||
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses({ limit: 200 });
|
||||
}, []);
|
||||
|
||||
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { tier: "premium", label: "", duration_days: 30, price: "", currency: "USD" },
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const result = await createPlan(values);
|
||||
if (!result) return;
|
||||
|
||||
const planId = String(result.plan_id);
|
||||
|
||||
if (selectedCourseIds.length > 0) {
|
||||
await syncPlanCourses(planId, selectedCourseIds);
|
||||
}
|
||||
|
||||
navigate("/admin/tiers/plans");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Add Plan - 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: "Plans", to: "/admin/tiers/plans" },
|
||||
{ label: "Add Plan" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Add Plan</h1>
|
||||
<p className="text-sm text-muted-foreground">Create a new premium or exclusive plan.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
{/* ── Plan Details ── */}
|
||||
<SectionCard title="Plan Details">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tier <span className="text-destructive">*</span></Label>
|
||||
<Select value={watch("tier")} onValueChange={(v) => setValue("tier", v, { shouldDirty: true })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select tier" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="exclusive">Exclusive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.tier?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
||||
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
|
||||
<FieldError message={errors.label?.message} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="duration_days">Duration (days) <span className="text-destructive">*</span></Label>
|
||||
<Input id="duration_days" type="number" min={1} {...register("duration_days")} />
|
||||
<FieldError message={errors.duration_days?.message} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
|
||||
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
|
||||
<FieldError message={errors.price?.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input id="currency" maxLength={3} placeholder="USD" {...register("currency")} />
|
||||
<FieldError message={errors.currency?.message} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Courses ── */}
|
||||
<SectionCard title="Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Assign courses included in this plan. You can also manage this later from the plan's detail page.
|
||||
</p>
|
||||
<CourseMultiSelect
|
||||
courses={courses}
|
||||
selected={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House, ChevronsUpDown, Check, X, BookOpen } from "lucide-react";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Command, CommandEmpty, CommandGroup,
|
||||
CommandInput, CommandItem, CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
const schema = z.object({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."),
|
||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||
currency: z.string().length(3),
|
||||
is_active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{title && <div className="pb-1 border-b"><h2 className="text-sm font-semibold">{title}</h2></div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CourseMultiSelect({ courses, selected, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedSet = new Set(selected.map(String));
|
||||
const selectedList = courses.filter((c) => selectedSet.has(String(c.course_id)));
|
||||
|
||||
const toggle = (id) => {
|
||||
const sid = String(id);
|
||||
onChange(
|
||||
selectedSet.has(sid)
|
||||
? selected.filter((x) => String(x) !== sid)
|
||||
: [...selected, sid]
|
||||
);
|
||||
};
|
||||
|
||||
const remove = (id) => onChange(selected.filter((x) => String(x) !== String(id)));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{selected.length === 0
|
||||
? <span className="text-muted-foreground">Select courses…</span>
|
||||
: <span>{selected.length} course{selected.length !== 1 ? "s" : ""} selected</span>
|
||||
}
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search courses…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<BookOpen className="size-6 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No courses found.</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{courses.map((course) => {
|
||||
const checked = selectedSet.has(String(course.course_id));
|
||||
return (
|
||||
<CommandItem
|
||||
key={course.course_id}
|
||||
value={`${course.title} ${course.course_code ?? ""}`}
|
||||
onSelect={() => toggle(String(course.course_id))}
|
||||
className="gap-2"
|
||||
>
|
||||
<div className={cn(
|
||||
"flex size-4 items-center justify-center rounded-sm border border-primary shrink-0",
|
||||
checked ? "bg-primary text-primary-foreground" : "opacity-50"
|
||||
)}>
|
||||
{checked && <Check className="size-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{course.title}</p>
|
||||
{course.level && (
|
||||
<p className="text-xs text-muted-foreground capitalize">{course.level}</p>
|
||||
)}
|
||||
</div>
|
||||
{course.course_code && (
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">{course.course_code}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{selectedList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedList.map((course) => (
|
||||
<Badge key={course.course_id} variant="secondary" className="gap-1 pr-1">
|
||||
<span className="text-xs max-w-[160px] truncate">{course.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(course.course_id)}
|
||||
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditPlan() {
|
||||
const navigate = useNavigate();
|
||||
const { planId } = useParams();
|
||||
const { fetchPlan, plan, updatePlan, fetchPlanCourses, planCourses, syncPlanCourses, loading } = useTiers();
|
||||
const { courses: allCourses, fetchCourses } = useCourses();
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState([]);
|
||||
|
||||
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
fetchPlanCourses(planId);
|
||||
fetchCourses({ page: 1, limit: 1000 });
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCourseIds(planCourses.map(c => String(c.course_id)));
|
||||
}, [planCourses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (plan) reset({
|
||||
label: plan.label,
|
||||
duration_days: plan.duration_days,
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
is_active: plan.is_active,
|
||||
});
|
||||
}, [plan]);
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const result = await updatePlan(planId, values);
|
||||
await syncPlanCourses(planId, selectedCourseIds);
|
||||
if (!result) return;
|
||||
navigate("/admin/tiers/plans");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={plan ? `Edit: ${plan.label} - STARR` : undefined} />
|
||||
<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: "Plans", to: "/admin/tiers/plans" },
|
||||
{ label: "Edit Plan" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Edit Plan</h1>
|
||||
<p className="text-sm text-muted-foreground capitalize">{plan?.tier} — {plan?.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !plan ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
<SectionCard title="Plan Details">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
||||
<Input id="label" {...register("label")} />
|
||||
<FieldError message={errors.label?.message} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="duration_days">Duration (days)</Label>
|
||||
<Input id="duration_days" type="number" min={1} {...register("duration_days")} />
|
||||
<FieldError message={errors.duration_days?.message} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="price">Price</Label>
|
||||
<Input id="price" type="number" step="0.01" {...register("price")} />
|
||||
<FieldError message={errors.price?.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input id="currency" maxLength={3} {...register("currency")} />
|
||||
<FieldError message={errors.currency?.message} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Active</p>
|
||||
<p className="text-xs text-muted-foreground">Inactive plans won't appear to users.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={watch("is_active") ?? true}
|
||||
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Assign courses included in this plan. Changes are saved when you click Save Changes.
|
||||
</p>
|
||||
<CourseMultiSelect
|
||||
courses={allCourses}
|
||||
selected={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { House } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import PaymentsTable from "../../components/tiers/PaymentsTable";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
export default function PaymentList() {
|
||||
const { fetchPayments } = useTiers();
|
||||
const [searchParams] = useSearchParams();
|
||||
const planId = searchParams.get("plan_id");
|
||||
|
||||
useEffect(() => {
|
||||
fetchPayments({
|
||||
filters: planId ? [{ field: "plan_id", value: planId }] : [],
|
||||
});
|
||||
}, [planId]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Tiers", to: "/admin/tiers/plans" },
|
||||
{ label: "Payments" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title="Payments - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
{planId && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing payments for Plan ID: <span className="font-medium">{planId}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<PaymentsTable planId={planId} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from "react";
|
||||
import { House } from "lucide-react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import TierPlansTable from "../../components/tiers/TierPlansTable";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
export default function PlanList() {
|
||||
const { fetchPlans } = useTiers();
|
||||
useEffect(() => { fetchPlans(); }, []);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Tiers", to: "/admin/tiers/plans" },
|
||||
{ label: "Plans" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title="Plans - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<TierPlansTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, House, ShieldPlus, ShieldOff, BadgeCheck } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
||||
AlertDialogContent, AlertDialogDescription,
|
||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
const TIER_BADGE = { free: "secondary", premium: "default", exclusive: "destructive" };
|
||||
const STATUS_BADGE = { active: "default", expired: "secondary", revoked: "outline" };
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||
<span className="text-sm font-medium">{children ?? <span className="text-muted-foreground italic">—</span>}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ icon: Icon, title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserTierList() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { userTiers, plans, loading, fetchUserTiers, fetchPlans, grantTier, revokeTier } = useTiers();
|
||||
|
||||
const [grantOpen, setGrantOpen] = useState(false);
|
||||
const [revokeTarget, setRevokeTarget] = useState(null);
|
||||
const [grantForm, setGrantForm] = useState({ tier: "premium", plan_id: "", notes: "" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserTiers(userId);
|
||||
fetchPlans();
|
||||
}, [userId]);
|
||||
|
||||
const activePlans = plans.filter((p) => p.is_active);
|
||||
const filteredPlans = activePlans.filter((p) => p.tier === grantForm.tier);
|
||||
|
||||
const handleGrant = async () => {
|
||||
if (!grantForm.plan_id) return;
|
||||
setSubmitting(true);
|
||||
const ok = await grantTier({ user_id: Number(userId), ...grantForm, plan_id: Number(grantForm.plan_id) });
|
||||
setSubmitting(false);
|
||||
if (ok) { setGrantOpen(false); fetchUserTiers(userId); }
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
setSubmitting(true);
|
||||
const ok = await revokeTier(revokeTarget.tier_id);
|
||||
setSubmitting(false);
|
||||
if (ok) { setRevokeTarget(null); fetchUserTiers(userId); }
|
||||
};
|
||||
|
||||
const activeTier = userTiers.find((t) => t.status === "active");
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="User Tier Management - 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: "Users", to: "/admin/users" },
|
||||
{ label: `User #${userId}`, to: `/admin/users/view/${userId}` },
|
||||
{ label: "Tiers" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">User Tiers</h1>
|
||||
<p className="text-sm text-muted-foreground">Tier history for User #{userId}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setGrantOpen(true)}>
|
||||
<ShieldPlus className="h-4 w-4 mr-2" />
|
||||
Grant Tier
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Active Tier Card */}
|
||||
{activeTier && (
|
||||
<div className="mb-5 rounded-lg border bg-card p-5 flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Current Tier</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={TIER_BADGE[activeTier.tier] ?? "outline"} className="capitalize text-sm px-3 py-0.5">
|
||||
{activeTier.tier}
|
||||
</Badge>
|
||||
{activeTier.expires_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Expires {new Date(activeTier.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{activeTier.tier !== "free" && (
|
||||
<Button size="sm" variant="destructive" onClick={() => setRevokeTarget(activeTier)}>
|
||||
<ShieldOff className="h-4 w-4 mr-2" />
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{loading && !userTiers.length ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<SectionCard icon={BadgeCheck} title="Tier History">
|
||||
{userTiers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No tier records found.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{userTiers.map((t) => (
|
||||
<div key={t.tier_id} className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={TIER_BADGE[t.tier] ?? "outline"} className="capitalize">{t.tier}</Badge>
|
||||
<Badge variant={STATUS_BADGE[t.status] ?? "outline"} className="capitalize">{t.status}</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">#{t.tier_id}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoRow label="Starts At">{t.starts_at ? new Date(t.starts_at).toLocaleString() : "—"}</InfoRow>
|
||||
<InfoRow label="Expires At">{t.expires_at ? new Date(t.expires_at).toLocaleString() : "Never"}</InfoRow>
|
||||
<InfoRow label="Granted By">{t.grantedByUser?.email ?? (t.granted_by ? `#${t.granted_by}` : "Self-serve")}</InfoRow>
|
||||
{t.revoked_at && (
|
||||
<InfoRow label="Revoked At">{new Date(t.revoked_at).toLocaleString()}</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
{t.notes && <p className="text-xs text-muted-foreground italic">{t.notes}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Grant Dialog ── */}
|
||||
<Dialog open={grantOpen} onOpenChange={setGrantOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Grant Tier</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tier</Label>
|
||||
<Select
|
||||
value={grantForm.tier}
|
||||
onValueChange={(v) => setGrantForm((p) => ({ ...p, tier: v, plan_id: "" }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="exclusive">Exclusive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Plan</Label>
|
||||
<Select
|
||||
value={grantForm.plan_id}
|
||||
onValueChange={(v) => setGrantForm((p) => ({ ...p, plan_id: v }))}
|
||||
disabled={!filteredPlans.length}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={filteredPlans.length ? "Select a plan" : "No active plans for this tier"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredPlans.map((p) => (
|
||||
<SelectItem key={p.plan_id} value={String(p.plan_id)}>
|
||||
{p.label} — {p.duration_days}d / {p.currency} {Number(p.price).toFixed(2)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="notes">Notes <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
rows={2}
|
||||
placeholder="Reason for manual grant..."
|
||||
value={grantForm.notes}
|
||||
onChange={(e) => setGrantForm((p) => ({ ...p, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={handleGrant} disabled={!grantForm.plan_id || submitting}>
|
||||
{submitting && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Grant
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Revoke Confirm ── */}
|
||||
<AlertDialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke tier?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The user's <strong className="capitalize">{revokeTarget?.tier}</strong> tier will be revoked
|
||||
and they'll be automatically downgraded to Free.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleRevoke}
|
||||
>
|
||||
{submitting && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, CreditCard, BadgeCheck, User } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
const STATUS_BADGE = {
|
||||
pending: "secondary",
|
||||
completed: "default",
|
||||
failed: "destructive",
|
||||
cancelled: "outline",
|
||||
expired: "outline",
|
||||
refunded: "outline",
|
||||
};
|
||||
const TIER_BADGE = { free: "secondary", premium: "default", exclusive: "destructive" };
|
||||
|
||||
// ─── Provider label map (extend as you add more providers) ───────────────────
|
||||
const PROVIDER_LABELS = {
|
||||
paypal: "PayPal",
|
||||
stripe: "Stripe",
|
||||
gcash: "GCash",
|
||||
};
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||
<span className="text-sm font-medium break-all">
|
||||
{children ?? <span className="text-muted-foreground italic">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ icon: Icon, title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Provider reference section — renders differently per provider ────────────
|
||||
function ProviderReference({ payment }) {
|
||||
const provider = payment.provider ?? "paypal";
|
||||
const payload = payment.provider_payload ?? {};
|
||||
const title = `${PROVIDER_LABELS[provider] ?? provider} Reference`;
|
||||
|
||||
return (
|
||||
<SectionCard icon={CreditCard} title={title}>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
|
||||
{/* PayPal */}
|
||||
{provider === "paypal" && (
|
||||
<>
|
||||
<InfoRow label="Order ID">{payload.order_id}</InfoRow>
|
||||
<InfoRow label="Capture ID">{payload.capture_id}</InfoRow>
|
||||
<InfoRow label="Payer ID">{payload.payer_id}</InfoRow>
|
||||
{payload.checkout && (
|
||||
<>
|
||||
<InfoRow label="Subtotal">
|
||||
{payment.currency} {Number(payload.checkout.subtotal ?? 0).toFixed(2)}
|
||||
</InfoRow>
|
||||
{Number(payload.checkout.discount) > 0 && (
|
||||
<InfoRow label="Promo Applied">
|
||||
{payload.checkout.promo_code} — -{payment.currency}{" "}
|
||||
{Number(payload.checkout.discount).toFixed(2)}
|
||||
</InfoRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stripe — extend when needed */}
|
||||
{provider === "stripe" && (
|
||||
<>
|
||||
<InfoRow label="Payment Intent ID">{payload.payment_intent_id}</InfoRow>
|
||||
<InfoRow label="Charge ID">{payload.charge_id}</InfoRow>
|
||||
<InfoRow label="Customer ID">{payload.customer_id}</InfoRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* GCash / other — generic fallback */}
|
||||
{provider !== "paypal" && provider !== "stripe" && (
|
||||
<>
|
||||
<InfoRow label="Reference ID">{payload.reference_id}</InfoRow>
|
||||
<InfoRow label="Transaction ID">{payload.transaction_id}</InfoRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancelled info — shown for any provider */}
|
||||
{payload.cancelled_at && (
|
||||
<InfoRow label="Cancelled At">
|
||||
{new Date(payload.cancelled_at).toLocaleString()}
|
||||
</InfoRow>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ViewPayment() {
|
||||
const navigate = useNavigate();
|
||||
const { paymentId } = useParams();
|
||||
const { fetchPayment, payment, loading } = useTiers();
|
||||
|
||||
useEffect(() => { fetchPayment(paymentId); }, [fetchPayment, paymentId]);
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={payment ? `${payment.plan?.label ?? 'Payment'} – Payment - STARR` : undefined} />
|
||||
<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: "Payments", to: "/admin/tiers/payments" },
|
||||
{ label: `Payment #${paymentId}` },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Payment Details</h1>
|
||||
<p className="text-sm text-muted-foreground">Payment #{paymentId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !payment ? (
|
||||
<div className="space-y-5">
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
|
||||
</div>
|
||||
) : !payment ? (
|
||||
<p className="text-sm text-muted-foreground">Payment not found.</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Transaction */}
|
||||
<SectionCard icon={CreditCard} title="Transaction">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Status">
|
||||
<Badge variant={STATUS_BADGE[payment.status] ?? "outline"} className="capitalize mt-0.5">
|
||||
{payment.status}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label="Provider">
|
||||
{PROVIDER_LABELS[payment.provider] ?? payment.provider ?? "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Amount">
|
||||
{payment.currency} {Number(payment.amount).toFixed(2)}
|
||||
</InfoRow>
|
||||
{Number(payment.discount) > 0 && (
|
||||
<InfoRow label="Discount">
|
||||
-{payment.currency} {Number(payment.discount).toFixed(2)}
|
||||
{payment.promo_code && ` (${payment.promo_code})`}
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow label="Paid At">
|
||||
{payment.paid_at ? new Date(payment.paid_at).toLocaleString() : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Created At">
|
||||
{payment.createdAt ? new Date(payment.createdAt).toLocaleString() : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* User */}
|
||||
<SectionCard icon={User} title="User">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="User ID">{String(payment.user_id)}</InfoRow>
|
||||
<InfoRow label="Email">{payment.user?.email}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Plan */}
|
||||
{payment.plan && (
|
||||
<SectionCard icon={BadgeCheck} title="Plan">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Label">{payment.plan.label}</InfoRow>
|
||||
<InfoRow label="Tier">
|
||||
<Badge variant={TIER_BADGE[payment.plan.tier] ?? "outline"} className="capitalize mt-0.5">
|
||||
{payment.plan.tier}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label="Duration">{payment.plan.duration_days} days</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Provider reference — dynamic per provider */}
|
||||
<ProviderReference payment={payment} />
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, BookOpen } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
const TIER_BADGE = { premium: "default", exclusive: "destructive" };
|
||||
const STATUS_BADGE = { true: "default", false: "secondary" };
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{children ?? <span className="text-muted-foreground italic">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ icon: Icon, title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ViewPlan() {
|
||||
const navigate = useNavigate();
|
||||
const { planId } = useParams();
|
||||
const { fetchPlan, fetchPlanCourses, planCourses, plan, loading } = useTiers();
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
fetchPlanCourses(planId);
|
||||
}, [planId]);
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
|
||||
<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: "Plans", to: "/admin/tiers/plans" },
|
||||
{ label: plan?.label ?? `Plan #${planId}` },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Plan Details</h1>
|
||||
<p className="text-sm text-muted-foreground">View plan information.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/tiers/plans/${planId}/edit`)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && !plan ? (
|
||||
<LoadingSkeleton />
|
||||
) : !plan ? (
|
||||
<p className="text-sm text-muted-foreground">Plan not found.</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
|
||||
<SectionCard icon={Tag} title="Plan Details">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Label">{plan.label}</InfoRow>
|
||||
<InfoRow label="Tier">
|
||||
<Badge variant={TIER_BADGE[plan.tier] ?? "outline"} className="capitalize mt-0.5">
|
||||
{plan.tier}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label="Duration">{plan.duration_days} days</InfoRow>
|
||||
<InfoRow label="Price">
|
||||
{plan.currency} {Number(plan.price).toFixed(2)}
|
||||
</InfoRow>
|
||||
<InfoRow label="Currency">{plan.currency}</InfoRow>
|
||||
<InfoRow label="Status">
|
||||
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
|
||||
{plan.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={BadgeCheck} title="Audit">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Created At">
|
||||
{plan.createdAt ? new Date(plan.createdAt).toLocaleString() : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Updated At">
|
||||
{plan.updatedAt ? new Date(plan.updatedAt).toLocaleString() : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={BookOpen} title="Assigned Courses">
|
||||
{planCourses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No courses assigned to this plan.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{planCourses.map((course) => (
|
||||
<div key={course.course_id} className="flex items-center justify-between rounded-lg border px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{course.title}</p>
|
||||
{course.course_code && (
|
||||
<p className="text-xs text-muted-foreground">{course.course_code}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="capitalize text-xs">{course.level ?? "—"}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -91,14 +92,9 @@ export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) {
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Creating..." : "Add group"}
|
||||
</Button>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// ─── pages/users/ViewUser.jsx ─────────────────────────────────────────────────
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useUsers } from "@/contexts/AdminUserContext";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Pencil, ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, Trophy, Award, BadgeCheck, Activity, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ROLE_CONFIG } from "@/data/profile.data";
|
||||
import { BADGE_STYLES } from "@/utils/table.util";
|
||||
import { getActionBadge } from "@/data/activity.data";
|
||||
import { timeAgo } from "@/utils/timestamp.util";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
const StatusBadge = ({ value }) => (
|
||||
@@ -17,15 +21,30 @@ const StatusBadge = ({ value }) => (
|
||||
</span>
|
||||
);
|
||||
|
||||
const ACHIEVEMENT_ICON = { badge: BadgeCheck, milestone: Trophy };
|
||||
|
||||
export default function ViewUser() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, fetchUser, loading } = useUsers();
|
||||
const {
|
||||
user, fetchUser, loading,
|
||||
achievements, achievementsLoading, fetchUserAchievements,
|
||||
activity, activityPagination, activityLoading, fetchUserActivity,
|
||||
} = useUsers();
|
||||
|
||||
const [activityPage, setActivityPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser(userId);
|
||||
fetchUserAchievements(userId);
|
||||
fetchUserActivity(userId, { page: 1, limit: 10 });
|
||||
}, [userId]);
|
||||
|
||||
const loadActivityPage = (p) => {
|
||||
setActivityPage(p);
|
||||
fetchUserActivity(userId, { page: p, limit: 10 });
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Spinner className="size-8" />
|
||||
@@ -58,9 +77,6 @@ export default function ViewUser() {
|
||||
<p className="text-muted-foreground text-sm">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`../edit/${userId}`)}>
|
||||
<Pencil className="size-4 mr-2" /> Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ─── Account Info ────────────────────────────────────────────────── */}
|
||||
@@ -122,6 +138,151 @@ export default function ViewUser() {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ─── Achievements ────────────────────────────────────────────────── */}
|
||||
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Trophy className="size-4" /> Achievements & Certificates
|
||||
</h2>
|
||||
{achievementsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="space-y-1 flex-1">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : achievements.length > 0 ? (
|
||||
<div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{achievements.map((a) => {
|
||||
const Icon = ACHIEVEMENT_ICON[a.type] ?? Award;
|
||||
const isCert = a.key.startsWith("course_completed_");
|
||||
return (
|
||||
<div key={a.achievement_id} className="flex items-start gap-3 p-3 rounded-lg border bg-muted/40">
|
||||
<div className={`h-8 w-8 rounded-full flex items-center justify-center shrink-0 ${isCert ? "bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-400" : "bg-secondary text-secondary-foreground"}`}>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{a.label}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{a.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{new Date(a.granted_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
{isCert && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 text-green-600 border-green-500">
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No achievements yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Activity ────────────────────────────────────────────────────── */}
|
||||
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Activity className="size-4" /> Activity Log
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{activityPagination.totalRecords > 0 && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{activityPagination.totalRecords} events
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={() => navigate(`/admin/users/${userId}/activity`)}
|
||||
>
|
||||
View all
|
||||
</Button>
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
{activityLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-32 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : activity.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No activity recorded yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border -mx-6">
|
||||
{activity.map((row) => {
|
||||
const { label, className } = getActionBadge(row.action);
|
||||
return (
|
||||
<div key={row.activity_id} className="flex items-center gap-3 px-6 py-2.5 hover:bg-muted/30 transition-colors">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border shrink-0 ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
{row.entity_type ? (
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{row.entity_type}{row.entity_id ? ` #${row.entity_id}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40">—</span>
|
||||
)}
|
||||
<span className="ml-auto">
|
||||
{row.created_at ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap cursor-default">
|
||||
{timeAgo(row.created_at)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{new Date(row.created_at).toLocaleString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
hour: "numeric", minute: "2-digit", second: "2-digit",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40">—</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activityPagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-2 border-t text-xs text-muted-foreground">
|
||||
<span>Page {activityPage} of {activityPagination.totalPages}</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline" size="icon" className="h-6 w-6"
|
||||
disabled={!activityPagination.hasPrevPage || activityLoading}
|
||||
onClick={() => loadActivityPage(activityPage - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="icon" className="h-6 w-6"
|
||||
disabled={!activityPagination.hasNextPage || activityLoading}
|
||||
onClick={() => loadActivityPage(activityPage + 1)}
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Audit ───────────────────────────────────────────────────────── */}
|
||||
<Section title="Audit Trail">
|
||||
<Field label="Created At">{user.createdAt ? new Date(user.createdAt).toLocaleString() : "—"}</Field>
|
||||
|
||||
@@ -16,7 +16,6 @@ import ViewUser from '../pages/users/ViewUser'
|
||||
// User Groups
|
||||
import GroupList from '../pages/user_groups/GroupList'
|
||||
import ViewGroup from '../pages/user_groups/ViewGroup'
|
||||
import EditUser from '../pages/users/EditUser'
|
||||
import ArchivedUserList from '../pages/users/ArchivedUserList'
|
||||
import ArchivedGroupList from '../pages/user_groups/ArchivedGroupList'
|
||||
|
||||
@@ -55,8 +54,11 @@ import ArchivedLessonsList from '../pages/courses/lessons/ArchivedLessonsList'
|
||||
import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder'
|
||||
import ViewLessonPage from '../pages/courses/lessons/ViewLessonPage'
|
||||
import CourseAssessment from '../pages/courses/CourseAssessment'
|
||||
import ViewAssessment from '../pages/courses/ViewAssessment'
|
||||
import UnitQuiz from '../pages/courses/units/UnitQuiz'
|
||||
import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
|
||||
|
||||
// Task List
|
||||
|
||||
import TaskList from '../pages/task_list/TaskList'
|
||||
import CreateTaskList from '../pages/task_list/CreateTaskList'
|
||||
@@ -65,10 +67,40 @@ import Tasks from '../pages/task_list/task/Tasks'
|
||||
import ArchiveTaskList from '../pages/task_list/ArchiveTaskList'
|
||||
import ViewTaskList from '../pages/task_list/ViewTaskList'
|
||||
|
||||
// Tasks (under Task List)
|
||||
|
||||
import CreateTask from '../pages/task_list/task/CreateTask'
|
||||
import EditTask from '../pages/task_list/task/EditTask'
|
||||
import ViewTask from '../pages/task_list/task/ViewTask'
|
||||
import ArchivedTask from '../pages/task_list/task/ArchiveTask'
|
||||
import ViewAudioAsset from '../pages/assets/ViewAudioAsset'
|
||||
|
||||
// Categories
|
||||
import CategoryList from '../pages/categories/CategoryList';
|
||||
import AddCategory from '../pages/categories/AddCategory';
|
||||
import EditCategory from '../pages/categories/EditCategory';
|
||||
|
||||
// Tiers
|
||||
import PlanList from '../pages/tiers/PlanList';
|
||||
import AddPlan from '../pages/tiers/AddPlan';
|
||||
import ViewPlan from '../pages/tiers/ViewPlan';
|
||||
import EditPlan from '../pages/tiers/EditPlan';
|
||||
import UserTierList from '../pages/tiers/UserTierList';
|
||||
import PaymentList from '../pages/tiers/PaymentList';
|
||||
import ViewPayment from '../pages/tiers/ViewPayment';
|
||||
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
|
||||
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
|
||||
|
||||
// Advertisements
|
||||
import AdvertisementList from '../pages/advertisements/AdvertisementList'
|
||||
import AddAdvertisement from '../pages/advertisements/AddAdvertisement'
|
||||
import EditAdvertisement from '../pages/advertisements/EditAdvertisement'
|
||||
import ViewAdvertisement from '../pages/advertisements/ViewAdvertisement'
|
||||
|
||||
// Activity
|
||||
import ActivityFeed from '../pages/activity/ActivityFeed'
|
||||
import UserActivityPage from '../pages/activity/UserActivityPage'
|
||||
|
||||
|
||||
|
||||
export const AdminRoutes = {
|
||||
@@ -90,7 +122,6 @@ export const AdminRoutes = {
|
||||
{ index: true, element: <UserList /> },
|
||||
{ path: 'add/staff', element: <AddUser /> },
|
||||
{ path: 'view/:userId', element: <ViewUser /> },
|
||||
{ path: 'edit/:userId', element: <EditUser /> },
|
||||
{ path: 'archived', element: <ArchivedUserList /> },
|
||||
]
|
||||
},
|
||||
@@ -117,6 +148,7 @@ export const AdminRoutes = {
|
||||
{ path: 'view/image/:assetId', element: <ViewImageAsset /> },
|
||||
{ path: 'view/video/:assetId', element: <ViewVideoAsset /> },
|
||||
{ path: 'view/document/:assetId', element: <ViewDocumentAsset /> },
|
||||
{ path: 'view/audio/:assetId', element: <ViewAudioAsset /> },
|
||||
{ path: 'edit/:assetId', element: <EditAsset /> },
|
||||
],
|
||||
},
|
||||
@@ -132,7 +164,19 @@ export const AdminRoutes = {
|
||||
{ path: ':courseId/view', element: <ViewCourse /> },
|
||||
{ path: ':courseId/edit', element: <EditCourse /> },
|
||||
{ path: ":courseId/assessment", element: <CourseAssessment /> },
|
||||
|
||||
{ path: ":courseId/assessment/view", element: <ViewAssessment /> },
|
||||
|
||||
// Categories
|
||||
{
|
||||
path: 'categories',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <CategoryList /> },
|
||||
{ path: 'add', element: <AddCategory /> },
|
||||
{ path: ':id/edit', element: <EditCategory /> },
|
||||
]
|
||||
},
|
||||
|
||||
// Units
|
||||
{
|
||||
path: ":courseId/units",
|
||||
@@ -144,6 +188,7 @@ export const AdminRoutes = {
|
||||
{ path: ':unitId/view', element: <ViewUnit /> },
|
||||
{ path: ':unitId/edit', element: <EditUnit /> },
|
||||
{ path: ":unitId/quiz", element: <UnitQuiz /> },
|
||||
{ path: ":unitId/quiz/view", element: <ViewUnitQuiz /> },
|
||||
|
||||
// Lessons
|
||||
{
|
||||
@@ -164,7 +209,7 @@ export const AdminRoutes = {
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
|
||||
// Task
|
||||
{
|
||||
path: 'taskList',
|
||||
@@ -184,10 +229,61 @@ export const AdminRoutes = {
|
||||
{ path: 'archived', element: <ArchivedTask /> },
|
||||
{ path: ':taskId/view', element: <ViewTask /> },
|
||||
{ path: ':taskId/edit', element: <EditTask /> },
|
||||
{ path: ':taskId/completions', element: <TaskSubmissions /> },
|
||||
{ path: ':taskId/completions/:completionId/view', element: <ViewTaskCompletion /> },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Tiers
|
||||
|
||||
{
|
||||
path: 'tiers',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{
|
||||
path: 'plans',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <PlanList /> },
|
||||
{ path: 'add', element: <AddPlan /> },
|
||||
{ path: ':planId/view', element: <ViewPlan /> },
|
||||
{ path: ':planId/edit', element: <EditPlan /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'users/:userId/tiers',
|
||||
element: <UserTierList />,
|
||||
},
|
||||
{
|
||||
path: 'payments',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <PaymentList /> },
|
||||
{ path: ':paymentId/view', element: <ViewPayment /> },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// Advertisements
|
||||
|
||||
{
|
||||
path: 'advertisements',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <AdvertisementList /> },
|
||||
{ path: 'add', element: <AddAdvertisement /> },
|
||||
{ path: ':advertisementId/view', element: <ViewAdvertisement /> },
|
||||
{ path: ':advertisementId/edit', element: <EditAdvertisement /> },
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
// Activity Feed
|
||||
{ path: 'activity', element: <ActivityFeed /> },
|
||||
{ path: 'users/:userId/activity', element: <UserActivityPage /> },
|
||||
|
||||
// Add here
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user