mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -17,6 +17,7 @@ import {
|
||||
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
|
||||
} from '@/components/ui/pagination';
|
||||
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
|
||||
import { useDateFormat } from '@/hooks/useDateFormat';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -64,6 +65,7 @@ function UserAvatar({ name, email, avatarUrl }) {
|
||||
|
||||
function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
const { detailCache, detailLoading, fetchUserReadingProgress } = useAdminCourseReadingProgress();
|
||||
const { fmtDate, fmtDateShort } = useDateFormat();
|
||||
const breakdown = entry ? detailCache[entry.user_id] : null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,9 +74,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
}
|
||||
}, [open, entry]);
|
||||
|
||||
const lastSeen = entry?.last_accessed_at
|
||||
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—';
|
||||
const lastSeen = entry?.last_accessed_at ? fmtDate(entry.last_accessed_at) : '—';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -154,7 +154,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
</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' })}
|
||||
{fmtDateShort(lesson.completed_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,9 +178,8 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
// ─── 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' })
|
||||
: '—';
|
||||
const { fmtDate } = useDateFormat();
|
||||
const lastSeen = entry.last_accessed_at ? fmtDate(entry.last_accessed_at) : '—';
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -51,7 +51,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`),
|
||||
onQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz/edit`),
|
||||
onViewLessons: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/lessons`),
|
||||
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`),
|
||||
onEdit: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/edit`),
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { BookOpen, Check, RotateCcw } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Command, CommandInput, CommandEmpty, CommandList, CommandItem } from "@/components/ui/command";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* CoursePicker
|
||||
* Props:
|
||||
* subscription — tier slug to filter courses (e.g. "premium"). Pass null/undefined to hide.
|
||||
* selectedIds — Set<string> of selected course_id strings
|
||||
* onChange — (Set<string>) => void
|
||||
*/
|
||||
export function CoursePicker({ subscription, selectedIds, onChange }) {
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!subscription) { setCourses([]); return; }
|
||||
setLoading(true);
|
||||
setSearch("");
|
||||
api.get(`/admin/courses/by-subscription?slug=${encodeURIComponent(subscription)}`)
|
||||
.then(({ data }) => setCourses(data.data ?? []))
|
||||
.catch(() => setCourses([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [subscription]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
if (!q) return courses;
|
||||
return courses.filter(
|
||||
(c) =>
|
||||
c.title?.toLowerCase().includes(q) ||
|
||||
c.description?.toLowerCase().includes(q)
|
||||
);
|
||||
}, [courses, search]);
|
||||
|
||||
const toggle = (id) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const checkAll = () => onChange(new Set(filtered.map((c) => String(c.course_id))));
|
||||
const resetAll = () => onChange(new Set());
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
|
||||
if (!subscription) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "Loading courses…"
|
||||
: `${courses.length} course${courses.length !== 1 ? "s" : ""} in this tier${selectedCount > 0 ? ` — ${selectedCount} selected` : ""}`
|
||||
}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
disabled={loading || filtered.length === 0}
|
||||
onClick={checkAll}
|
||||
>
|
||||
<Check className="size-3 mr-1" />
|
||||
Check all
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={resetAll}
|
||||
>
|
||||
<RotateCcw className="size-3 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-12 w-full rounded-lg" />)}
|
||||
</div>
|
||||
) : courses.length === 0 ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed p-5 text-sm text-muted-foreground">
|
||||
<BookOpen className="size-4 shrink-0" />
|
||||
No courses found with subscription <span className="font-mono font-medium ml-1">"{subscription}"</span>.
|
||||
</div>
|
||||
) : (
|
||||
<Command className="rounded-lg border shadow-none" shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search courses…"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{filtered.length === 0 ? (
|
||||
<CommandEmpty>No courses match your search.</CommandEmpty>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
{filtered.map((course) => {
|
||||
const id = String(course.course_id);
|
||||
const checked = selectedIds.has(id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={id}
|
||||
value={id}
|
||||
onSelect={() => toggle(id)}
|
||||
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggle(id)}
|
||||
className="mt-0.5 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-medium leading-snug">{course.title}</span>
|
||||
{course.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{course.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo, useRef, useCallback } from "react";
|
||||
import { useMemo, useRef, useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import api from "@/utils/api.util";
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { buildDataColumns, columnPinning } from "../../config/tiers/payments/columns.config";
|
||||
@@ -18,6 +20,7 @@ export default function PaymentsTable({ planId = null }) {
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const {
|
||||
payments, paymentAttributes,
|
||||
@@ -25,6 +28,13 @@ export default function PaymentsTable({ planId = null }) {
|
||||
loading, fetchPayments,
|
||||
} = useTiers();
|
||||
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const exportConfig = {
|
||||
allData: payments,
|
||||
attributes: paymentAttributes,
|
||||
@@ -51,8 +61,8 @@ export default function PaymentsTable({ planId = null }) {
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(paymentAttributes, rowActions),
|
||||
[paymentAttributes]
|
||||
() => buildDataColumns(paymentAttributes, rowActions, fmtDateTime, tierMap),
|
||||
[paymentAttributes, fmtDateTime, tierMap]
|
||||
);
|
||||
|
||||
// Pass planId as a locked filter to DataTable's onFetch
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo, useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMemo, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
@@ -24,6 +26,17 @@ export default function TierPlansTable() {
|
||||
bulkDeletePlans, bulkRestorePlans,
|
||||
} = useTiers();
|
||||
|
||||
const [hasAvailableCategories, setHasAvailableCategories] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => {
|
||||
const available = (data.data ?? []).filter((c) => !c.is_default && c.is_active);
|
||||
setHasAvailableCategories(available.length > 0);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
@@ -82,14 +95,15 @@ export default function TierPlansTable() {
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchPlans,
|
||||
pagination: planPagination,
|
||||
pagination: planPagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
showArchived,
|
||||
onToggleArchived: handleToggleArchived,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
hasAvailableCategories,
|
||||
onToggleArchived: handleToggleArchived,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
@@ -108,6 +122,18 @@ export default function TierPlansTable() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasAvailableCategories && (
|
||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-4">
|
||||
<Layers className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No tier categories defined.{" "}
|
||||
<Link to="/admin/tiers/categories/add" className="underline text-primary font-medium">
|
||||
Add a tier category
|
||||
</Link>{" "}
|
||||
before creating plans.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
title="Tier Plans"
|
||||
data={plans}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useDashboard } from "@/contexts/AdminDashboardContext";
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||
import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog";
|
||||
import { UnbanDialog } from "@/components/generic/Dialogs/UnbanDialog";
|
||||
import { TableDashboard } from "@/components/generic/Dashboard/TableDashboard";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "../../config/users/columns.config";
|
||||
@@ -22,6 +24,10 @@ import { getTimestamp } from "@/utils/timestamp.util";
|
||||
export default function UsersTable() {
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
const [archiveIds, setArchiveIds] = useState(null);
|
||||
const [banTarget, setBanTarget] = useState(null);
|
||||
const [banIds, setBanIds] = useState(null);
|
||||
const [unbanTarget, setUnbanTarget] = useState(null);
|
||||
const [unbanIds, setUnbanIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
@@ -35,6 +41,7 @@ export default function UsersTable() {
|
||||
const {
|
||||
users, attributes, pagination, setPagination, loading,
|
||||
fetchUsers, fetchUserFieldValues, deactivateUser, deactivateUsers,
|
||||
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers,
|
||||
} = useUsers();
|
||||
|
||||
const { usersDashboard, fetchUsersDashboard } = useDashboard();
|
||||
@@ -55,7 +62,12 @@ export default function UsersTable() {
|
||||
sheetName: "Users",
|
||||
};
|
||||
|
||||
const rowActions = buildRowActions({ navigate, onArchive: (row) => setArchiveTarget(row) });
|
||||
const rowActions = buildRowActions({
|
||||
navigate,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onBan: (row) => setBanTarget(row),
|
||||
onUnban: (row) => setUnbanTarget(row),
|
||||
});
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchUsers, pagination, exportConfig, navigate,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
@@ -64,8 +76,10 @@ export default function UsersTable() {
|
||||
});
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
archiveUser: (row) => setArchiveTarget(row),
|
||||
archiveUser: (row) => setArchiveTarget(row),
|
||||
archiveUsers: (ids) => setArchiveIds(ids),
|
||||
banUsers: (ids) => setBanIds(ids),
|
||||
unbanUsers: (ids) => setUnbanIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
@@ -79,6 +93,20 @@ export default function UsersTable() {
|
||||
fetchUsersDashboard();
|
||||
};
|
||||
|
||||
const handleBanSuccess = () => {
|
||||
setBanTarget(null);
|
||||
setBanIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchUsers({ page: 1, limit: pagination.limit });
|
||||
};
|
||||
|
||||
const handleUnbanSuccess = () => {
|
||||
setUnbanTarget(null);
|
||||
setUnbanIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchUsers({ page: 1, limit: pagination.limit });
|
||||
};
|
||||
|
||||
// ─── Attach filterId + filterValue to each stat so TableDashboard
|
||||
// knows which column/value to apply when clicked ──────────────────────
|
||||
const dashboardStats = (usersDashboard?.stats ?? []).map((s) => ({ // ← was dashboard?.users?.stats
|
||||
@@ -167,6 +195,52 @@ export default function UsersTable() {
|
||||
loading={loading}
|
||||
onSuccess={handleArchiveSuccess}
|
||||
/>
|
||||
|
||||
{/* Single ban */}
|
||||
<BanUserDialog
|
||||
open={!!banTarget}
|
||||
onOpenChange={(v) => !v && setBanTarget(null)}
|
||||
entity={banTarget}
|
||||
entityLabel="User"
|
||||
getName={(u) => u?.personal_info?.name?.full_name ?? u?.email}
|
||||
onBan={(payload) => banUser(banTarget?.user_id, payload)}
|
||||
loading={loading}
|
||||
onSuccess={handleBanSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk ban */}
|
||||
<BanUserDialog
|
||||
open={!!banIds}
|
||||
onOpenChange={(v) => !v && setBanIds(null)}
|
||||
ids={banIds ?? []}
|
||||
entityLabel="User"
|
||||
onBan={(payload) => bulkBanUsers({ ids: banIds, ...payload })}
|
||||
loading={loading}
|
||||
onSuccess={handleBanSuccess}
|
||||
/>
|
||||
|
||||
{/* Single unban */}
|
||||
<UnbanDialog
|
||||
open={!!unbanTarget}
|
||||
onOpenChange={(v) => !v && setUnbanTarget(null)}
|
||||
entity={unbanTarget}
|
||||
entityLabel="User"
|
||||
getName={(u) => u?.personal_info?.name?.full_name ?? u?.email}
|
||||
onUnban={(payload) => unbanUser(unbanTarget?.user_id, payload)}
|
||||
loading={loading}
|
||||
onSuccess={handleUnbanSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk unban */}
|
||||
<UnbanDialog
|
||||
open={!!unbanIds}
|
||||
onOpenChange={(v) => !v && setUnbanIds(null)}
|
||||
ids={unbanIds ?? []}
|
||||
entityLabel="User"
|
||||
onUnban={(payload) => bulkUnbanUsers({ ids: unbanIds, ...payload })}
|
||||
loading={loading}
|
||||
onSuccess={handleUnbanSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
@@ -17,39 +18,35 @@ const STATUS_BADGE = {
|
||||
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)}
|
||||
export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v ? new Date(v).toLocaleString() : "—", tierMap = {}) {
|
||||
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) => {
|
||||
const { cls, label } = resolveTierBadge(info.getValue(), tierMap);
|
||||
return <Badge className={`${cls} capitalize`}>{label}</Badge>;
|
||||
},
|
||||
paid_at: (info) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{fmtDateTime(info.getValue())}
|
||||
</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>
|
||||
),
|
||||
|
||||
};
|
||||
),
|
||||
"user.email": (info) => (
|
||||
<span className="text-sm">{info.getValue() ?? "—"}</span>
|
||||
),
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { Plus, RefreshCw, Download, Archive, Layers } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
@@ -7,6 +7,7 @@ export function buildToolbarActions({
|
||||
exportConfig,
|
||||
navigate,
|
||||
showArchived,
|
||||
hasAvailableCategories,
|
||||
onToggleArchived,
|
||||
getFilters,
|
||||
getSort,
|
||||
@@ -38,13 +39,21 @@ export function buildToolbarActions({
|
||||
tableInstance: getTableInstance(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "categories",
|
||||
type: "button",
|
||||
label: "Tier Categories",
|
||||
icon: <Layers className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => navigate("/admin/tiers/categories"),
|
||||
},
|
||||
{
|
||||
key: "create",
|
||||
type: "button",
|
||||
label: "New Plan",
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
hidden: showArchived,
|
||||
hidden: showArchived || !hasAvailableCategories,
|
||||
onClick: () => navigate("/admin/tiers/plans/add"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
// modules/admin/config/user_groups/view/rowActions.config.jsx
|
||||
|
||||
import { UserMinus } from "lucide-react";
|
||||
import { UserMinus, UserCheck } from "lucide-react";
|
||||
|
||||
/**
|
||||
* @param {Object} deps
|
||||
* @param {Function} deps.onRemove Opens remove-member confirm dialog
|
||||
* @param {Function} deps.onRemove Opens remove-member confirm dialog
|
||||
* @param {Function} [deps.onAssign] Opens assign-to-group dialog (NOGRP only)
|
||||
* @param {boolean} [deps.isNoGroup] Switches to assign mode when viewing NOGRP
|
||||
* @returns {Array} rowActions
|
||||
*/
|
||||
export function buildRowActions({ onRemove }) {
|
||||
export function buildRowActions({ onRemove, onAssign, isNoGroup }) {
|
||||
if (isNoGroup) {
|
||||
return [
|
||||
{
|
||||
key: "assign",
|
||||
label: "Assign to Group",
|
||||
icon: <UserCheck className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onAssign(row),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: "remove",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// modules/admin/config/user_groups/view/selection.config.jsx
|
||||
|
||||
import { Download, UserMinus } from "lucide-react";
|
||||
import { Download, UserMinus, UserCheck } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
/**
|
||||
@@ -8,9 +8,11 @@ import { exportTableToExcel } from "@/utils/excel.util";
|
||||
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
|
||||
* @param {Function} deps.onRemoveMember Opens single remove dialog (row)
|
||||
* @param {Function} deps.onRemoveMembers Opens bulk remove dialog (ids[])
|
||||
* @param {Function} [deps.onAssignMembers] Opens assign dialog (ids[]) — NOGRP only
|
||||
* @param {boolean} [deps.isNoGroup] Switches to assign mode when viewing NOGRP
|
||||
*/
|
||||
export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMembers }) {
|
||||
return [
|
||||
export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMembers, onAssignMembers, isNoGroup }) {
|
||||
const actions = [
|
||||
{
|
||||
key: "export-selected",
|
||||
label: "Export",
|
||||
@@ -18,7 +20,17 @@ export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMe
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }),
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (isNoGroup) {
|
||||
actions.push({
|
||||
key: "assign-selected",
|
||||
label: "Assign to Group",
|
||||
icon: <UserCheck className="h-3.5 w-3.5" />,
|
||||
onClick: (rows) => onAssignMembers(rows.map((r) => r.user_id)),
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
key: "remove-selected",
|
||||
label: "Remove",
|
||||
icon: <UserMinus className="h-3.5 w-3.5" />,
|
||||
@@ -26,9 +38,11 @@ export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMe
|
||||
onClick: (rows) => {
|
||||
const ids = rows.map((r) => r.user_id);
|
||||
ids.length === 1
|
||||
? onRemoveMember(rows[0]) // single confirm dialog
|
||||
: onRemoveMembers(ids); // bulk confirm dialog
|
||||
? onRemoveMember(rows[0])
|
||||
: onRemoveMembers(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -3,15 +3,17 @@
|
||||
//
|
||||
// Each onClick receives the row's data object from buildRowActionsColumn.
|
||||
|
||||
import { Eye, Pencil, Archive } from "lucide-react";
|
||||
import { Eye, Pencil, Archive, ShieldBan, ShieldCheck } from "lucide-react";
|
||||
|
||||
/**
|
||||
* @param {Object} deps
|
||||
* @param {Function} deps.navigate React Router navigate
|
||||
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||
* @param {Function} deps.navigate React Router navigate
|
||||
* @param {Function} deps.onArchive Opens archive dialog
|
||||
* @param {Function} deps.onBan Opens ban dialog
|
||||
* @param {Function} deps.onUnban Opens unban dialog
|
||||
* @returns {Array} rowActions
|
||||
*/
|
||||
export function buildRowActions({ navigate, onArchive }) {
|
||||
export function buildRowActions({ navigate, onArchive, onBan, onUnban }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
@@ -26,14 +28,31 @@ export function buildRowActions({ navigate, onArchive }) {
|
||||
onClick: (row) => navigate(`edit/${row.user_id}`),
|
||||
disabled: (row) => row.role === "super_admin",
|
||||
},
|
||||
{
|
||||
key: "ban",
|
||||
label: "Ban User",
|
||||
className: "text-destructive focus:text-destructive",
|
||||
icon: <ShieldBan className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onBan(row),
|
||||
hidden: (row) => !!row.is_banned || !row.is_active,
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "unban",
|
||||
label: "Unban User",
|
||||
className: "text-emerald-600 focus:text-emerald-600",
|
||||
icon: <ShieldCheck className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onUnban(row),
|
||||
hidden: (row) => !row.is_banned,
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
className: "text-destructive focus:text-destructive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onArchive(row), // ← opens dialog, not deactivateUser
|
||||
hidden: (row) => !row.is_active, // ← hide if already inactive
|
||||
separator: true,
|
||||
onClick: (row) => onArchive(row),
|
||||
hidden: (row) => !row.is_active,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
// config/selection.config.jsx
|
||||
import { Download, Archive, Trash2 } from "lucide-react";
|
||||
import { Download, Archive, ShieldBan, ShieldCheck } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
/**
|
||||
* @param {Object} deps
|
||||
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
|
||||
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||
* @param {Function} deps.deleteUser Delete handler from useManagement
|
||||
*/
|
||||
export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers, getTableInstance }) {
|
||||
export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers, banUsers, unbanUsers, getTableInstance }) {
|
||||
return [
|
||||
{
|
||||
key: "export-selected",
|
||||
@@ -17,6 +11,27 @@ export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table ?? getTableInstance() }),
|
||||
},
|
||||
{
|
||||
key: "ban-selected",
|
||||
label: "Ban",
|
||||
icon: <ShieldBan 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.filter((r) => !r.is_banned).map((r) => r.user_id);
|
||||
if (ids.length) banUsers(ids);
|
||||
},
|
||||
hidden: (rows) => rows.every((r) => r.is_banned || !r.is_active),
|
||||
},
|
||||
{
|
||||
key: "unban-selected",
|
||||
label: "Unban",
|
||||
icon: <ShieldCheck className="h-3.5 w-3.5" />,
|
||||
onClick: (rows) => {
|
||||
const ids = rows.filter((r) => r.is_banned).map((r) => r.user_id);
|
||||
if (ids.length) unbanUsers(ids);
|
||||
},
|
||||
hidden: (rows) => rows.every((r) => !r.is_banned),
|
||||
},
|
||||
{
|
||||
key: "archive-selected",
|
||||
label: "Archive",
|
||||
@@ -25,8 +40,8 @@ export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers,
|
||||
onClick: (rows) => {
|
||||
const ids = rows.map((r) => r.user_id);
|
||||
ids.length === 1
|
||||
? archiveUser(rows[0]) // opens single dialog
|
||||
: archiveUsers(ids); // opens bulk dialog
|
||||
? archiveUser(rows[0])
|
||||
: archiveUsers(ids);
|
||||
},
|
||||
hidden: (rows) => rows.every((r) => r.status === "archived"),
|
||||
},
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
|
||||
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
||||
import { timeAgo } from "@/utils/timestamp.util";
|
||||
import { fmtISO } from "@/utils/datetime.util";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
const BREADCRUMB = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
@@ -25,7 +27,7 @@ const LIMIT = 20;
|
||||
|
||||
function toDateStr(d) {
|
||||
if (!d) return undefined;
|
||||
return d.toLocaleDateString("en-CA"); // YYYY-MM-DD
|
||||
return fmtISO(d);
|
||||
}
|
||||
|
||||
export default function ActivityFeed() {
|
||||
@@ -196,10 +198,9 @@ export default function ActivityFeed() {
|
||||
// ─── DatePickerButton ─────────────────────────────────────────────────────────
|
||||
function DatePickerButton({ value, onChange, placeholder, disabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const label = value
|
||||
? value.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
: placeholder;
|
||||
const label = value ? fmtDate(value) : placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -252,6 +253,7 @@ function initials(name, email) {
|
||||
}
|
||||
|
||||
function ActivityRow({ row, onViewUser }) {
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
const { label, className } = getActionBadge(row.action);
|
||||
const ts = row.created_at;
|
||||
const displayName = row.full_name ?? row.email ?? `User #${row.user_id}`;
|
||||
@@ -294,10 +296,7 @@ function ActivityRow({ row, onViewUser }) {
|
||||
<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",
|
||||
})}
|
||||
{fmtDateTime(ts)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
|
||||
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
||||
import { timeAgo } from "@/utils/timestamp.util";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
@@ -156,6 +157,7 @@ export default function UserActivityPage() {
|
||||
|
||||
// ─── Item ──────────────────────────────────────────────────────────────────────
|
||||
function ActivityItem({ row }) {
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
const { label, className } = getActionBadge(row.action);
|
||||
const ts = row.created_at;
|
||||
|
||||
@@ -188,10 +190,7 @@ function ActivityItem({ row }) {
|
||||
<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",
|
||||
})}
|
||||
{fmtDateTime(ts)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -162,6 +163,7 @@ function StatCard({ label, value, tone = "default" }) {
|
||||
// ─── Advertisement card ─────────────────────────────────────────────────────
|
||||
|
||||
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
||||
const { fmtDate } = useDateFormat();
|
||||
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
|
||||
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
|
||||
const TypeIcon = typeMeta.icon ?? Megaphone;
|
||||
@@ -169,7 +171,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
||||
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);
|
||||
const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDate);
|
||||
|
||||
return (
|
||||
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
|
||||
@@ -255,12 +257,10 @@ function EmptyState({ onCreate }) {
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDateRange(start, end) {
|
||||
function formatDateRange(start, end, fmtDate) {
|
||||
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)}`;
|
||||
if (start && end) return `${fmtDate(start)} - ${fmtDate(end)}`;
|
||||
if (start) return `Starts ${fmtDate(start)}`;
|
||||
if (end) return `Ends ${fmtDate(end)}`;
|
||||
return null;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { House, Edit, ArrowLeft, Megaphone, MousePointerClick, ExternalLink } fr
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -37,19 +38,13 @@ function Field({ label, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 { fmtDateTime } = useDateFormat();
|
||||
|
||||
const [advertisement, setAdvertisement] = useState(null);
|
||||
|
||||
@@ -171,8 +166,8 @@ export default function ViewAdvertisement() {
|
||||
{/* ── 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="Start date">{fmtDateTime(advertisement.start_date)}</Field>
|
||||
<Field label="End date">{fmtDateTime(advertisement.end_date)}</Field>
|
||||
<Field label="Order">{advertisement.order ?? 0}</Field>
|
||||
<Field label="Active">{advertisement.is_active ? "Yes" : "No"}</Field>
|
||||
</div>
|
||||
@@ -191,9 +186,9 @@ export default function ViewAdvertisement() {
|
||||
<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="Created at">{fmtDateTime(advertisement.createdAt)}</Field>
|
||||
<Field label="Last updated by">{advertisement.updater?.full_name || "—"}</Field>
|
||||
<Field label="Last updated at">{formatDateTime(advertisement.updatedAt)}</Field>
|
||||
<Field label="Last updated at">{fmtDateTime(advertisement.updatedAt)}</Field>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -29,6 +30,7 @@ function MetaRow({ label, value }) {
|
||||
export default function ViewAudioAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
@@ -138,8 +140,8 @@ export default function ViewAudioAsset() {
|
||||
<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} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -26,6 +27,7 @@ const PREVIEWABLE = ["pdf", "txt", "html", "htm", "csv", "md"];
|
||||
export default function ViewDocumentAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
@@ -132,8 +134,8 @@ export default function ViewDocumentAsset() {
|
||||
<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} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -24,6 +25,7 @@ function MetaRow({ label, value }) {
|
||||
export default function ViewImageAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
@@ -124,8 +126,8 @@ export default function ViewImageAsset() {
|
||||
<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} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -34,6 +35,7 @@ function formatDuration(seconds) {
|
||||
export default function ViewVideoAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
@@ -160,8 +162,8 @@ export default function ViewVideoAsset() {
|
||||
<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} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -6,6 +7,7 @@ import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,7 +31,7 @@ const schema = z.object({
|
||||
course_code: z.string().optional(),
|
||||
order_index: z.coerce.number().min(0).default(0),
|
||||
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
subscription: z.enum(["free", "premium"]).default("free"),
|
||||
subscription: z.string().min(1, "Subscription is required.").default("free"),
|
||||
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
|
||||
});
|
||||
|
||||
@@ -61,6 +63,13 @@ export default function AddCourse() {
|
||||
const { createCourse, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -178,8 +187,11 @@ export default function AddCourse() {
|
||||
<SelectValue placeholder="Select subscription" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
{tierCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.subscription?.message} />
|
||||
|
||||
@@ -16,9 +16,9 @@ import api from "@/utils/api.util";
|
||||
|
||||
// ── Dirty-check snapshot ──────────────────────────────────────────────────────
|
||||
|
||||
function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions }) {
|
||||
function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }) {
|
||||
return JSON.stringify({
|
||||
title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours,
|
||||
title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions,
|
||||
questions: questions.map((q) => ({
|
||||
question_id: q.question_id ?? null,
|
||||
question: q.question,
|
||||
@@ -222,6 +222,7 @@ export default function CourseAssessment() {
|
||||
const [maxQuestions, setMaxQuestions] = useState("");
|
||||
const [maxAttempts, setMaxAttempts] = useState(3);
|
||||
const [cooldownHours, setCooldownHours] = useState(24);
|
||||
const [shuffleQuestions, setShuffleQuestions] = useState(false);
|
||||
|
||||
// ── Update confirmation dialog ─────────────────────────────────────────────
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
@@ -253,10 +254,11 @@ export default function CourseAssessment() {
|
||||
const mq = assessment.max_questions ?? "";
|
||||
const ma = assessment.max_attempts ?? 3;
|
||||
const ch = assessment.cooldown_hours ?? 24;
|
||||
const sq = assessment.shuffle_questions === true || assessment.shuffle_questions === 1;
|
||||
const qs = (assessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
|
||||
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
|
||||
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setQuestions(qs);
|
||||
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, questions: qs });
|
||||
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setShuffleQuestions(sq); setQuestions(qs);
|
||||
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, shuffleQuestions: sq, questions: qs });
|
||||
}, [assessment]);
|
||||
|
||||
// ── Measure sticky header → --assessment-h ────────────────────────────────
|
||||
@@ -366,7 +368,7 @@ export default function CourseAssessment() {
|
||||
// ── Dirty tracking ─────────────────────────────────────────────────────────
|
||||
const isDirty = initialSnapshot.current === null
|
||||
? questions.length > 0
|
||||
: snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions }) !== initialSnapshot.current;
|
||||
: snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }) !== initialSnapshot.current;
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
@@ -384,8 +386,9 @@ export default function CourseAssessment() {
|
||||
time_limit_minutes: timeLimit ? parseInt(timeLimit) : null,
|
||||
is_required: isRequired,
|
||||
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
|
||||
max_attempts: parseInt(maxAttempts) || 3,
|
||||
cooldown_hours: parseInt(cooldownHours) || 24,
|
||||
max_attempts: parseInt(maxAttempts) || 3,
|
||||
cooldown_hours: parseInt(cooldownHours) || 24,
|
||||
shuffle_questions: shuffleQuestions,
|
||||
updatedBy: user?.user_id,
|
||||
createdBy: user?.user_id,
|
||||
};
|
||||
@@ -430,7 +433,7 @@ export default function CourseAssessment() {
|
||||
}
|
||||
}
|
||||
|
||||
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions });
|
||||
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
|
||||
};
|
||||
|
||||
const handleConfirmSave = async () => {
|
||||
@@ -589,6 +592,17 @@ export default function CourseAssessment() {
|
||||
Required to complete course
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="assessment_shuffle"
|
||||
checked={shuffleQuestions === true}
|
||||
onCheckedChange={(val) => setShuffleQuestions(val)}
|
||||
/>
|
||||
<Label htmlFor="assessment_shuffle" className="cursor-pointer">
|
||||
Shuffle question order for each attempt
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{maxQuestions && parseInt(maxQuestions) < questions.length && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
|
||||
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -47,7 +48,7 @@ const schema = z.object({
|
||||
course_code: z.string().optional(),
|
||||
order_index: z.coerce.number().min(0).default(0),
|
||||
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
subscription: z.enum(["free", "premium"]).default("free"),
|
||||
subscription: z.string().min(1, "Subscription is required.").default("free"),
|
||||
objectives: z
|
||||
.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
|
||||
.default([]),
|
||||
@@ -100,6 +101,14 @@ export default function EditCourse() {
|
||||
const { categories: allCategories, fetchCategories } = useCategories();
|
||||
const { user } = useAuth();
|
||||
|
||||
// ─── Tier categories ──────────────────────────────────────────────────────
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ─── Categories state ─────────────────────────────────────────────────────
|
||||
const [selectedCategoryIds, setSelectedCategoryIds] = useState([]);
|
||||
const [categoriesDirty, setCategoriesDirty] = useState(false);
|
||||
@@ -290,7 +299,7 @@ export default function EditCourse() {
|
||||
|
||||
const result = await updateCourse(courseId, payload);
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
navigate(`/admin/courses/${courseId}/view`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -393,16 +402,17 @@ 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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
{tierCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.subscription?.message} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -103,6 +104,7 @@ function QuestionView({ question, index }) {
|
||||
// ─── Completions tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function CompletionRow({ row }) {
|
||||
const { fmtDate, fmtDateTime } = useDateFormat();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
@@ -112,8 +114,15 @@ function CompletionRow({ row }) {
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{row.full_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.email}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium">{row.full_name ?? "Unknown User"}</p>
|
||||
{row.deleted && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
|
||||
Deleted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{row.email ?? "—"}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
|
||||
@@ -128,7 +137,7 @@ function CompletionRow({ row }) {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"}
|
||||
{row.latest_at ? fmtDate(row.latest_at) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />}
|
||||
@@ -158,7 +167,7 @@ function CompletionRow({ row }) {
|
||||
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
|
||||
: <span className="text-red-500 font-medium">Fail</span>}
|
||||
</td>
|
||||
<td className="py-1.5 text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</td>
|
||||
<td className="py-1.5 text-muted-foreground">{fmtDateTime(a.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -235,6 +244,7 @@ function fmtDuration(secs) {
|
||||
}
|
||||
|
||||
function SessionsTab({ sessions, loading }) {
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
if (loading) return <div className="space-y-3">{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-10 w-full rounded-lg" />)}</div>;
|
||||
|
||||
if (!sessions) return (
|
||||
@@ -274,8 +284,15 @@ function SessionsTab({ sessions, loading }) {
|
||||
{rows.map((s) => (
|
||||
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium">{s.full_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.email}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium">{s.full_name ?? "Unknown User"}</p>
|
||||
{s.deleted && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
|
||||
Deleted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{s.email ?? "—"}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<Badge className={SESSION_BADGE[s.status] ?? ""}>
|
||||
@@ -283,10 +300,10 @@ function SessionsTab({ sessions, loading }) {
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(s.started_at).toLocaleString()}
|
||||
{fmtDateTime(s.started_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{s.expires_at ? new Date(s.expires_at).toLocaleString() : "—"}
|
||||
{s.expires_at ? fmtDateTime(s.expires_at) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm">{fmtDuration(s.time_spent_seconds)}</td>
|
||||
</tr>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
@@ -45,8 +46,7 @@ const LEVEL_BADGE = {
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_BADGE = {
|
||||
free: "secondary",
|
||||
premium: "default",
|
||||
free: "secondary",
|
||||
};
|
||||
|
||||
function LoadingSkeleton() {
|
||||
@@ -69,6 +69,7 @@ export default function ViewCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, course, loading } = useCourses();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourse(courseId);
|
||||
@@ -232,10 +233,10 @@ export default function ViewCourse() {
|
||||
<InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow>
|
||||
<InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow>
|
||||
<InfoRow label="Created At">
|
||||
{course.createdAt ? new Date(course.createdAt).toLocaleString() : "—"}
|
||||
{course.createdAt ? fmtDateTime(course.createdAt) : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Updated At">
|
||||
{course.updatedAt ? new Date(course.updatedAt).toLocaleString() : "—"}
|
||||
{course.updatedAt ? fmtDateTime(course.updatedAt) : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +14,7 @@ export default function LessonsList() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchCourse, fetchUnit, course, unit, loading } = useCourses();
|
||||
const { fmtDate } = useDateFormat();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,13 +83,13 @@ export default function LessonsList() {
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
|
||||
<p className="font-semibold text-sm">
|
||||
{unit?.createdAt ? new Date(unit.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
||||
{unit?.createdAt ? fmtDate(unit.createdAt) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
|
||||
<p className="font-semibold text-sm">
|
||||
{unit?.updatedAt ? new Date(unit.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
||||
{unit?.updatedAt ? fmtDate(unit.updatedAt) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -21,6 +22,7 @@ export default function ViewLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, course, unit } = useCourses();
|
||||
const { fmtDate } = useDateFormat();
|
||||
const [lesson, setLesson] = useState(null);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
@@ -40,9 +42,6 @@ export default function ViewLesson() {
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (iso) =>
|
||||
iso ? new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—";
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} />
|
||||
@@ -83,11 +82,11 @@ export default function ViewLesson() {
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
|
||||
<p className="font-semibold text-sm">{formatDate(lesson?.createdAt)}</p>
|
||||
<p className="font-semibold text-sm">{fmtDate(lesson?.createdAt)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
|
||||
<p className="font-semibold text-sm">{formatDate(lesson?.updatedAt)}</p>
|
||||
<p className="font-semibold text-sm">{fmtDate(lesson?.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function EditUnit() {
|
||||
if (!isDirty) return navigate(-1);
|
||||
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
navigate(`/admin/courses/${courseId}/units/${unitId}/view`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+23
-9
@@ -30,9 +30,9 @@ function validate(questions) {
|
||||
|
||||
// ── Dirty-check snapshot (stable fields only, strips internal _tempId) ────────
|
||||
|
||||
function snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }) {
|
||||
function snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }) {
|
||||
return JSON.stringify({
|
||||
title, passingScore, isRequired, maxQuestions,
|
||||
title, passingScore, isRequired, maxQuestions, shuffleQuestions,
|
||||
questions: questions.map((q) => ({
|
||||
question_id: q.question_id ?? null,
|
||||
question: q.question,
|
||||
@@ -192,7 +192,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UnitQuiz() {
|
||||
export default function ModifyQuiz() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const {
|
||||
@@ -211,6 +211,7 @@ export default function UnitQuiz() {
|
||||
const [passingScore, setPassingScore] = useState(70);
|
||||
const [isRequired, setIsRequired] = useState(false);
|
||||
const [maxQuestions, setMaxQuestions] = useState("");
|
||||
const [shuffleQuestions, setShuffleQuestions] = useState(false);
|
||||
|
||||
const questionRefs = useRef([]);
|
||||
const navItemRefs = useRef([]);
|
||||
@@ -241,9 +242,10 @@ export default function UnitQuiz() {
|
||||
const ps = quiz.passing_score ?? 70;
|
||||
const ir = quiz.is_required === true || quiz.is_required === 1;
|
||||
const mq = quiz.max_questions ?? "";
|
||||
const sq = quiz.shuffle_questions === true || quiz.shuffle_questions === 1;
|
||||
const qs = (quiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
|
||||
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setQuestions(qs);
|
||||
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, questions: qs });
|
||||
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setShuffleQuestions(sq); setQuestions(qs);
|
||||
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, shuffleQuestions: sq, questions: qs });
|
||||
}, [quiz]);
|
||||
|
||||
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
|
||||
@@ -353,7 +355,7 @@ export default function UnitQuiz() {
|
||||
// ── Dirty tracking ─────────────────────────────────────────────────────────
|
||||
const isDirty = initialSnapshot.current === null
|
||||
? questions.length > 0 // new quiz — enable once they've added a question
|
||||
: snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }) !== initialSnapshot.current;
|
||||
: snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }) !== initialSnapshot.current;
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
@@ -368,8 +370,9 @@ export default function UnitQuiz() {
|
||||
const meta = {
|
||||
title: title || "Unit Quiz",
|
||||
passing_score: passingScore,
|
||||
is_required: isRequired,
|
||||
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
|
||||
is_required: isRequired,
|
||||
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
|
||||
shuffle_questions: shuffleQuestions,
|
||||
updatedBy: user?.user_id,
|
||||
createdBy: user?.user_id,
|
||||
};
|
||||
@@ -391,7 +394,7 @@ export default function UnitQuiz() {
|
||||
}
|
||||
}
|
||||
|
||||
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, questions });
|
||||
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
@@ -507,6 +510,17 @@ export default function UnitQuiz() {
|
||||
Required to proceed to next unit
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="quiz_shuffle"
|
||||
checked={shuffleQuestions === true}
|
||||
onCheckedChange={(val) => setShuffleQuestions(val)}
|
||||
/>
|
||||
<Label htmlFor="quiz_shuffle" className="cursor-pointer">
|
||||
Shuffle question order for each attempt
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{maxQuestions && parseInt(maxQuestions) < questions.length && (
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -13,6 +14,7 @@ export default function UnitsList() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, course, loading } = useCourses();
|
||||
const { fmtDate } = useDateFormat();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,7 +41,7 @@ export default function UnitsList() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={course ? `Units – ${course.title} - STARR` : undefined} />
|
||||
<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} />
|
||||
@@ -81,13 +83,13 @@ export default function UnitsList() {
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
|
||||
<p className="font-semibold text-sm">
|
||||
{course?.createdAt ? new Date(course.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
||||
{course?.createdAt ? fmtDate(course.createdAt) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
|
||||
<p className="font-semibold text-sm">
|
||||
{course?.updatedAt ? new Date(course.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
||||
{course?.updatedAt ? fmtDate(course.updatedAt) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CheckCircle2, Circle, Users,
|
||||
ChevronDown, ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
@@ -103,6 +104,7 @@ function QuestionView({ question, index }) {
|
||||
|
||||
function CompletionRow({ row }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { fmtDate, fmtDateTime } = useDateFormat();
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
@@ -111,8 +113,15 @@ function CompletionRow({ row }) {
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{row.full_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.email}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium">{row.full_name ?? "Unknown User"}</p>
|
||||
{row.deleted && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
|
||||
Deleted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{row.email ?? "—"}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
|
||||
@@ -127,7 +136,7 @@ function CompletionRow({ row }) {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"}
|
||||
{row.latest_at ? fmtDate(row.latest_at) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />}
|
||||
@@ -157,7 +166,7 @@ function CompletionRow({ row }) {
|
||||
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
|
||||
: <span className="text-red-500 font-medium">Fail</span>}
|
||||
</td>
|
||||
<td className="py-1.5 text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</td>
|
||||
<td className="py-1.5 text-muted-foreground">{fmtDateTime(a.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -284,7 +293,7 @@ export default function ViewUnitQuiz() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}
|
||||
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}
|
||||
>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Modify Quiz
|
||||
@@ -321,7 +330,7 @@ export default function ViewUnitQuiz() {
|
||||
<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`)}>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}>
|
||||
<NotebookPen className="h-4 w-4 mr-2" />
|
||||
Create Quiz
|
||||
</Button>
|
||||
|
||||
@@ -41,6 +41,8 @@ export default function CreateTaskList() {
|
||||
if (selectedGroupIds.length > 0) {
|
||||
await assignGroups(created.task_list_id, selectedGroupIds);
|
||||
}
|
||||
|
||||
navigate(`/admin/taskList/${created.task_list_id}/view`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
import { useDateFormat } from '@/hooks/useDateFormat';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -136,6 +137,7 @@ export default function ViewTaskList() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId } = useParams();
|
||||
const { fetchTaskList } = useAdminTask();
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const [taskList, setTaskList] = useState(null);
|
||||
|
||||
@@ -273,11 +275,7 @@ export default function ViewTaskList() {
|
||||
<span className="text-sm">
|
||||
Deadline:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{new Date(task.deadline).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{fmtDate(task.deadline)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function EditTask() {
|
||||
requirements: form.requirements,
|
||||
});
|
||||
|
||||
if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}`);
|
||||
if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/view`);
|
||||
};
|
||||
|
||||
if (!form) return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
import { useDateFormat } from '@/hooks/useDateFormat';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -128,6 +129,7 @@ export default function ViewTask() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId, taskId } = useParams();
|
||||
const { fetchTask } = useAdminTask();
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const [task, setTask] = useState(null);
|
||||
|
||||
@@ -200,11 +202,7 @@ export default function ViewTask() {
|
||||
<span className="text-sm">
|
||||
Deadline:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{new Date(task.deadline).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{fmtDate(task.deadline)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,280 +1,203 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } 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 { ArrowLeft, House } 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 { 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";
|
||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── 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"),
|
||||
tier_category_id: z.string().min(1, "Tier category is required."),
|
||||
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>;
|
||||
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>
|
||||
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>
|
||||
)}
|
||||
{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 navigate = useNavigate();
|
||||
const { createPlan, loading } = useTiers();
|
||||
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [catLoading, setCatLoading] = useState(true);
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses({ limit: 200 });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setCategories((data.data ?? []).filter((c) => !c.is_default && c.is_active)))
|
||||
.catch(() => {})
|
||||
.finally(() => setCatLoading(false));
|
||||
}, []);
|
||||
|
||||
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { tier: "premium", label: "", duration_days: 30, price: "", currency: "USD" },
|
||||
});
|
||||
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { tier_category_id: "", label: "", duration_days: 30, price: "", currency: "USD" },
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const result = await createPlan(values);
|
||||
if (!result) return;
|
||||
const selectedCategoryId = watch("tier_category_id");
|
||||
|
||||
const planId = String(result.plan_id);
|
||||
// Derive the subscription slug from the chosen category
|
||||
const categorySlug = useMemo(() => {
|
||||
if (!selectedCategoryId) return null;
|
||||
return categories.find((c) => String(c.tier_category_id) === selectedCategoryId)?.slug ?? null;
|
||||
}, [selectedCategoryId, categories]);
|
||||
|
||||
if (selectedCourseIds.length > 0) {
|
||||
await syncPlanCourses(planId, selectedCourseIds);
|
||||
}
|
||||
// Reset picker when category changes
|
||||
useEffect(() => {
|
||||
setSelectedCourseIds(new Set());
|
||||
}, [categorySlug]);
|
||||
|
||||
navigate("/admin/tiers/plans");
|
||||
};
|
||||
const onSubmit = async (values) => {
|
||||
const result = await createPlan(values);
|
||||
if (!result) return;
|
||||
|
||||
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">
|
||||
// Sync selected courses
|
||||
if (selectedCourseIds.size > 0) {
|
||||
await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
|
||||
course_ids: [...selectedCourseIds].map(Number),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
<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>
|
||||
navigate(`/admin/tiers/plans`);
|
||||
};
|
||||
|
||||
<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>
|
||||
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">
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<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>
|
||||
|
||||
{/* ── 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="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 paid tier plan.</p>
|
||||
</div>
|
||||
</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>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
<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} />
|
||||
<SectionCard title="Plan Details">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tier Category <span className="text-destructive">*</span></Label>
|
||||
{catLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||
<Spinner className="h-4 w-4" /> Loading categories…
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No tier categories found.{" "}
|
||||
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
|
||||
</p>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedCategoryId}
|
||||
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select tier category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
|
||||
{c.name}
|
||||
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<FieldError message={errors.tier_category_id?.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>
|
||||
|
||||
{categorySlug && (
|
||||
<SectionCard title="Assigned Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Select which <span className="font-medium capitalize">{categorySlug}</span> courses are included in this plan.
|
||||
</p>
|
||||
<CoursePicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={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 || catLoading || !selectedCategoryId}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</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>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ 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 { ArrowLeft, House } 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";
|
||||
@@ -13,14 +12,9 @@ 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";
|
||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const schema = z.object({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
@@ -44,110 +38,14 @@ function SectionCard({ title, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 { fetchPlan, plan, updatePlan, loading } = useTiers();
|
||||
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
||||
|
||||
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -155,28 +53,41 @@ export default function EditPlan() {
|
||||
|
||||
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,
|
||||
});
|
||||
if (plan) {
|
||||
reset({
|
||||
label: plan.label,
|
||||
duration_days: plan.duration_days,
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
is_active: plan.is_active,
|
||||
});
|
||||
}
|
||||
}, [plan]);
|
||||
|
||||
// Load existing assigned courses once the plan is known
|
||||
useEffect(() => {
|
||||
if (!planId || coursesLoaded) return;
|
||||
api.get(`/admin/tiers/plans/${planId}/courses`)
|
||||
.then(({ data }) => {
|
||||
const ids = (data.data ?? []).map((c) => String(c.course_id));
|
||||
setSelectedCourseIds(new Set(ids));
|
||||
setCoursesLoaded(true);
|
||||
})
|
||||
.catch(() => setCoursesLoaded(true));
|
||||
}, [planId]);
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const result = await updatePlan(planId, values);
|
||||
await syncPlanCourses(planId, selectedCourseIds);
|
||||
if (!result) return;
|
||||
|
||||
// Always sync (empty array clears all assignments)
|
||||
await api.post(`/admin/tiers/plans/${planId}/courses`, {
|
||||
course_ids: [...selectedCourseIds].map(Number),
|
||||
}).catch(() => {});
|
||||
|
||||
navigate("/admin/tiers/plans");
|
||||
};
|
||||
|
||||
@@ -249,16 +160,18 @@ export default function EditPlan() {
|
||||
</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>
|
||||
{plan?.tier && (
|
||||
<SectionCard title="Assigned Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Select which <span className="font-medium capitalize">{plan.tier}</span> courses are included in this plan.
|
||||
</p>
|
||||
<CoursePicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={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>
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft, House, ShieldCheck, ImagePlus, X, Check,
|
||||
Shield, Star, Trophy, Medal, Award, BadgeCheck, Gem,
|
||||
Crown, Zap, Flame, Sparkles, Rocket, Target, Hexagon, Layers, CircleDot,
|
||||
} from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const BADGE_ICON_OPTIONS = [
|
||||
{ name: "ShieldCheck", icon: ShieldCheck },
|
||||
{ name: "Shield", icon: Shield },
|
||||
{ name: "BadgeCheck", icon: BadgeCheck },
|
||||
{ name: "Star", icon: Star },
|
||||
{ name: "Crown", icon: Crown },
|
||||
{ name: "Gem", icon: Gem },
|
||||
{ name: "Trophy", icon: Trophy },
|
||||
{ name: "Medal", icon: Medal },
|
||||
{ name: "Award", icon: Award },
|
||||
{ name: "Sparkles", icon: Sparkles },
|
||||
{ name: "Flame", icon: Flame },
|
||||
{ name: "Zap", icon: Zap },
|
||||
{ name: "Rocket", icon: Rocket },
|
||||
{ name: "Target", icon: Target },
|
||||
{ name: "Hexagon", icon: Hexagon },
|
||||
{ name: "Layers", icon: Layers },
|
||||
{ name: "CircleDot", icon: CircleDot },
|
||||
];
|
||||
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 AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
import { AssetsProvider } from "@/contexts/AdminAssetsContext";
|
||||
import {
|
||||
AdminTierCategoriesProvider,
|
||||
useAdminTierCategories,
|
||||
} from "@/contexts/AdminTierCategoriesContext";
|
||||
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function BadgePicker({ currentAsset, selectedAsset, onSelect, onClear }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const display = selectedAsset ?? currentAsset;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||
{display?.file_url ? (
|
||||
<img src={display.file_url} alt={display.display_name} className="w-12 h-12 object-contain" />
|
||||
) : (
|
||||
<ShieldCheck className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
|
||||
{display ? "Change image" : "Pick from assets"}
|
||||
</Button>
|
||||
{display && (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={onClear}>
|
||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{display && <p className="text-xs text-muted-foreground truncate max-w-xs">{display.display_name}</p>}
|
||||
<AssetPickerSheet open={open} onOpenChange={setOpen} fileType="image" onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditTierCategoryInner({ isAdd }) {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { category, loading, fetchCategory, createCategory, updateCategory } = useAdminTierCategories();
|
||||
|
||||
const [slug, setSlug] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [rank, setRank] = useState(1);
|
||||
const [color, setColor] = useState("purple");
|
||||
const [badgeIcon, setBadgeIcon] = useState(null);
|
||||
const [badgeLabel, setBadgeLabel] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
const [clearBadge, setClearBadge] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdd && id) fetchCategory(id);
|
||||
}, [id, isAdd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (category && !isAdd) {
|
||||
setSlug(category.slug ?? "");
|
||||
setName(category.name ?? "");
|
||||
setDescription(category.description ?? "");
|
||||
setRank(category.rank ?? 0);
|
||||
setColor(category.color ?? (category.is_default ? "green" : "purple"));
|
||||
setBadgeIcon(category.badge_icon ?? null);
|
||||
setBadgeLabel(category.badge_label ?? "");
|
||||
setIsActive(category.is_active ?? true);
|
||||
setSelectedAsset(null);
|
||||
setClearBadge(false);
|
||||
}
|
||||
}, [category, isAdd]);
|
||||
|
||||
const validate = () => {
|
||||
const e = {};
|
||||
if (!name.trim()) e.name = "Name is required.";
|
||||
if (isAdd && !slug.trim()) e.slug = "Slug is required.";
|
||||
if (isAdd && !/^[a-z0-9_-]+$/.test(slug)) e.slug = "Slug must be lowercase letters, numbers, hyphens or underscores.";
|
||||
setErrors(e);
|
||||
return !Object.keys(e).length;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
rank: Number(rank),
|
||||
color,
|
||||
badge_icon: badgeIcon || null,
|
||||
badge_label: badgeLabel.trim() || null,
|
||||
is_active: isActive,
|
||||
};
|
||||
|
||||
if (selectedAsset) payload.badge_asset_id = selectedAsset.asset_id;
|
||||
else if (clearBadge) payload.badge_asset_id = null;
|
||||
|
||||
if (isAdd) {
|
||||
payload.slug = slug.trim();
|
||||
const result = await createCategory(payload);
|
||||
if (result) navigate("/admin/tiers/categories");
|
||||
} else {
|
||||
const result = await updateCategory(id, payload);
|
||||
if (result) navigate("/admin/tiers/categories");
|
||||
}
|
||||
};
|
||||
|
||||
const isLocked = !isAdd && category?.is_default;
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={isAdd ? "Add Tier Category - STARR" : "Edit Tier Category - 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: "Tier Categories", to: "/admin/tiers/categories" },
|
||||
{ label: isAdd ? "Add Category" : (category?.name ?? "Edit") },
|
||||
]} />
|
||||
</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">{isAdd ? "Add Tier Category" : "Edit Tier Category"}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isAdd ? "Define a new tier level for the platform." : "Update this tier category's details and badge."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Details */}
|
||||
<SectionCard title="Category Details">
|
||||
{isAdd && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slug">Slug <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase())}
|
||||
placeholder="e.g. gold"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Lowercase, no spaces. Cannot be changed after creation.</p>
|
||||
<FieldError message={errors.slug} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
|
||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Gold" />
|
||||
<FieldError message={errors.name} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="Optional short description." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rank">Rank (ordering)</Label>
|
||||
<Input id="rank" type="number" min={isLocked ? 0 : 1} value={rank} onChange={(e) => setRank(e.target.value)} className="w-32" />
|
||||
<p className="text-xs text-muted-foreground">Must be greater than 0. Free is rank 0. Higher rank = higher access tier.</p>
|
||||
{!isLocked && Number(rank) <= 0 && (
|
||||
<p className="text-xs text-destructive">Rank must be at least 1 — rank 0 is reserved for the Free (default) tier.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TIER_COLOR_OPTIONS.map((opt) => {
|
||||
const selected = color === opt.key;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
title={opt.label}
|
||||
onClick={() => setColor(opt.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border-2 transition-all ${selected ? "border-foreground scale-105" : "border-transparent opacity-70 hover:opacity-100"}`}
|
||||
style={{ backgroundColor: opt.swatch, color: "#fff" }}
|
||||
>
|
||||
{selected && <Check className="size-3" />}
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<Badge className={`${getTierColor(color).badge} text-xs`}>
|
||||
{name || "Preview"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLocked && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="is_active" checked={isActive} onCheckedChange={setIsActive} />
|
||||
<Label htmlFor="is_active">Active</Label>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Badge */}
|
||||
<SectionCard title="Badge">
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Shown on the user's profile when they are in this tier. Upload an image or pick a Lucide icon — image takes priority if both are set.
|
||||
</p>
|
||||
|
||||
{/* Lucide icon picker */}
|
||||
<div className="space-y-2">
|
||||
<Label>Icon <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* None option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBadgeIcon(null)}
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 text-xs text-muted-foreground transition-all ${!badgeIcon ? "border-foreground bg-muted scale-105" : "border-border hover:border-muted-foreground"}`}
|
||||
title="No icon"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
{BADGE_ICON_OPTIONS.map(({ name, icon: Icon }) => {
|
||||
const selected = badgeIcon === name;
|
||||
const cls = getTierColor(color).badge;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
title={name}
|
||||
onClick={() => setBadgeIcon(name)}
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 transition-all ${selected ? `${cls} border-foreground scale-105` : "border-border hover:border-muted-foreground"}`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{badgeIcon && (
|
||||
<p className="text-xs text-muted-foreground">Selected: <span className="font-medium">{badgeIcon}</span></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image upload */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Image <span className="text-muted-foreground text-xs">(overrides icon)</span></Label>
|
||||
<BadgePicker
|
||||
currentAsset={clearBadge ? null : (category?.badgeAsset ?? null)}
|
||||
selectedAsset={selectedAsset}
|
||||
onSelect={(a) => { setSelectedAsset(a); setClearBadge(false); }}
|
||||
onClear={() => { setSelectedAsset(null); setClearBadge(true); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="badge_label">Badge Label</Label>
|
||||
<Input
|
||||
id="badge_label"
|
||||
value={badgeLabel}
|
||||
onChange={(e) => setBadgeLabel(e.target.value)}
|
||||
placeholder="e.g. Premium Member"
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
{isAdd ? "Create Category" : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Wrapper({ isAdd }) {
|
||||
return (
|
||||
<AssetsProvider>
|
||||
<AdminTierCategoriesProvider>
|
||||
<EditTierCategoryInner isAdd={isAdd} />
|
||||
</AdminTierCategoriesProvider>
|
||||
</AssetsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddTierCategory() { return <Wrapper isAdd={true} />; }
|
||||
export function EditTierCategory() { return <Wrapper isAdd={false} />; }
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, ShieldCheck, Plus, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { AdminTierPoliciesProvider, useAdminTierPolicies } from "@/contexts/AdminTierPoliciesContext";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Rule type definitions ────────────────────────────────────────────────────
|
||||
|
||||
const RULE_DEFS = {
|
||||
course_subscription_access: {
|
||||
label: "Course Subscription Access",
|
||||
description: "Which course subscription levels this plan can unlock.",
|
||||
default: { type: "course_subscription_access", levels: ["free"] },
|
||||
},
|
||||
required_active_tier: {
|
||||
label: "Required Active Tier",
|
||||
description: "User's tier must be at least this rank to access content.",
|
||||
default: { type: "required_active_tier", tier: "" },
|
||||
},
|
||||
group_restriction: {
|
||||
label: "Group Restriction",
|
||||
description: "Only users in selected groups can access content.",
|
||||
default: { type: "group_restriction", group_ids: [] },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Rule Editors ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CourseAccessEditor({ rule, onChange }) {
|
||||
const [availableLevels, setAvailableLevels] = useState(["free"]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => {
|
||||
setAvailableLevels((data.data ?? []).filter((c) => c.is_active).map((c) => c.slug));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggle = (level) => {
|
||||
const levels = rule.levels ?? [];
|
||||
onChange({ ...rule, levels: levels.includes(level) ? levels.filter((l) => l !== level) : [...levels, level] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{availableLevels.map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
onClick={() => toggle(lvl)}
|
||||
className={`px-3 py-1 rounded-full border text-sm capitalize transition-colors ${
|
||||
(rule.levels ?? []).includes(lvl)
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-card border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequiredTierEditor({ rule, onChange }) {
|
||||
const [categories, setCategories] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setCategories((data.data ?? []).filter((c) => c.is_active && !c.is_default)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Select value={rule.tier} onValueChange={(v) => onChange({ ...rule, tier: v })}>
|
||||
<SelectTrigger className="w-48 mt-2">
|
||||
<SelectValue placeholder="Select tier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRestrictionEditor({ rule, onChange }) {
|
||||
const [groups, setGroups] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/groups").then(({ data }) => setGroups(data.data?.data ?? [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleGroup = (gid) => {
|
||||
const ids = rule.group_ids ?? [];
|
||||
onChange({ ...rule, group_ids: ids.includes(gid) ? ids.filter((id) => id !== gid) : [...ids, gid] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1 max-h-48 overflow-y-auto">
|
||||
{groups.length === 0 && <p className="text-xs text-muted-foreground">No groups found.</p>}
|
||||
{groups.map((g) => {
|
||||
const selected = (rule.group_ids ?? []).includes(Number(g.group_id));
|
||||
return (
|
||||
<button
|
||||
key={g.group_id}
|
||||
type="button"
|
||||
onClick={() => toggleGroup(Number(g.group_id))}
|
||||
className={`w-full text-left px-3 py-1.5 rounded border text-sm transition-colors ${
|
||||
selected ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{g.name}
|
||||
{g.group_code && <span className="ml-2 text-xs opacity-60">{g.group_code}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleCard({ rule, index, onChange, onRemove }) {
|
||||
const def = RULE_DEFS[rule.type];
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{def?.label ?? rule.type}</p>
|
||||
<p className="text-xs text-muted-foreground">{def?.description}</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" className="shrink-0" onClick={onRemove}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{rule.type === "course_subscription_access" && <CourseAccessEditor rule={rule} onChange={(r) => onChange(index, r)} />}
|
||||
{rule.type === "required_active_tier" && <RequiredTierEditor rule={rule} onChange={(r) => onChange(index, r)} />}
|
||||
{rule.type === "group_restriction" && <GroupRestrictionEditor rule={rule} onChange={(r) => onChange(index, r)} />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inner page ───────────────────────────────────────────────────────────────
|
||||
|
||||
function PlanPolicyInner() {
|
||||
const navigate = useNavigate();
|
||||
const { planId } = useParams();
|
||||
const { fetchPlan, plan, loading: planLoading } = useTiers();
|
||||
const { fetchPlanPolicy, savePlanPolicy, policy, loading } = useAdminTierPolicies();
|
||||
|
||||
const [rules, setRules] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
fetchPlanPolicy(planId);
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (policy) setRules(policy.access_rules ?? []);
|
||||
}, [policy]);
|
||||
|
||||
const handleRuleChange = (index, updated) =>
|
||||
setRules((prev) => prev.map((r, i) => (i === index ? updated : r)));
|
||||
|
||||
const removeRule = (index) =>
|
||||
setRules((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
const addRule = (type) => {
|
||||
const def = RULE_DEFS[type];
|
||||
if (!def) return;
|
||||
setRules((prev) => [...prev, { ...def.default }]);
|
||||
};
|
||||
|
||||
const usedTypes = new Set(rules.map((r) => r.type));
|
||||
|
||||
const handleSave = async () => {
|
||||
await savePlanPolicy(planId, { access_rules: rules });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={plan ? `Policy — ${plan.label}` : "Plan Policy"} />
|
||||
<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}`, to: `/admin/tiers/plans/${planId}/view` },
|
||||
{ label: "Policy" },
|
||||
]} />
|
||||
</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">Access Policy</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{planLoading ? <Skeleton className="h-4 w-40 inline-block" /> : (plan?.label ?? `Plan #${planId}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSave} disabled={loading} size="sm">
|
||||
{loading ? "Saving…" : "Save Policy"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
|
||||
<SectionCard icon={ShieldCheck} title="Access Rules">
|
||||
{rules.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No rules defined. Content access falls back to tier rank comparison.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule, i) => (
|
||||
<RuleCard
|
||||
key={`${rule.type}-${i}`}
|
||||
rule={rule}
|
||||
index={i}
|
||||
onChange={handleRuleChange}
|
||||
onRemove={() => removeRule(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Add Rule</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(RULE_DEFS).map(([type, def]) => (
|
||||
<Button
|
||||
key={type}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={usedTypes.has(type)}
|
||||
onClick={() => addRule(type)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
{def.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{plan && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Badge variant="outline" className="capitalize">{plan.tier}</Badge>
|
||||
<span className="text-sm text-muted-foreground">{plan.label}</span>
|
||||
<span className="text-sm text-muted-foreground">·</span>
|
||||
<span className="text-sm text-muted-foreground">{plan.duration_days} days</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlanPolicy() {
|
||||
return (
|
||||
<AdminTierPoliciesProvider>
|
||||
<PlanPolicyInner />
|
||||
</AdminTierPoliciesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { House, ShieldCheck, ImagePlus, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { AdminTierPoliciesProvider, useAdminTierPolicies } from "@/contexts/AdminTierPoliciesContext";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
import { AssetsProvider } from "@/contexts/AdminAssetsContext";
|
||||
|
||||
// ─── Badge editor card for a single system badge ──────────────────────────────
|
||||
|
||||
function SystemBadgeCard({ badge: initialBadge }) {
|
||||
const { saveSystemBadge, loading } = useAdminTierPolicies();
|
||||
|
||||
const [badge, setBadge] = useState(initialBadge);
|
||||
// selectedAsset: newly chosen from picker (not yet saved)
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
const [clearAsset, setClearAsset] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBadge(initialBadge);
|
||||
setSelectedAsset(null);
|
||||
setClearAsset(false);
|
||||
}, [initialBadge]);
|
||||
|
||||
const currentAsset = clearAsset ? null : (badge.asset ?? null);
|
||||
const displayAsset = selectedAsset ?? currentAsset;
|
||||
|
||||
const handleSelect = (asset) => {
|
||||
setSelectedAsset(asset);
|
||||
setClearAsset(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSelectedAsset(null);
|
||||
setClearAsset(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {
|
||||
label: badge.label ?? "",
|
||||
description: badge.description ?? "",
|
||||
information: badge.information ?? "",
|
||||
active_from: badge.active_from ?? null,
|
||||
active_until: badge.active_until ?? null,
|
||||
};
|
||||
|
||||
if (selectedAsset) {
|
||||
payload.asset_id = selectedAsset.asset_id;
|
||||
} else if (clearAsset) {
|
||||
payload.asset_id = null;
|
||||
}
|
||||
|
||||
const saved = await saveSystemBadge(badge.key, payload);
|
||||
if (saved) {
|
||||
setBadge(saved);
|
||||
setSelectedAsset(null);
|
||||
setClearAsset(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold capitalize">{badge.label || badge.key}</h2>
|
||||
<span className="ml-auto text-xs text-muted-foreground font-mono">{badge.key}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* ── Image picker ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{displayAsset ? (
|
||||
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center overflow-hidden">
|
||||
<img src={displayAsset.file_url} alt={displayAsset.display_name} className="w-12 h-12 object-contain" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center text-muted-foreground">
|
||||
<ShieldCheck className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}>
|
||||
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
|
||||
{displayAsset ? "Change image" : "Pick from assets"}
|
||||
</Button>
|
||||
{displayAsset && (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={handleClear}>
|
||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{displayAsset && (
|
||||
<p className="text-xs text-muted-foreground truncate max-w-xs">{displayAsset.display_name}</p>
|
||||
)}
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="image"
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Label / Description / Information ── */}
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Label</label>
|
||||
<Input value={badge.label ?? ""} onChange={(e) => setBadge((p) => ({ ...p, label: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Description</label>
|
||||
<Input value={badge.description ?? ""} onChange={(e) => setBadge((p) => ({ ...p, description: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Information</label>
|
||||
<Textarea value={badge.information ?? ""} onChange={(e) => setBadge((p) => ({ ...p, information: e.target.value }))} rows={2} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Active window ── */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Active From</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={badge.active_from ?? ""}
|
||||
onChange={(e) => setBadge((p) => ({ ...p, active_from: e.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Active Until</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={badge.active_until ?? ""}
|
||||
onChange={(e) => setBadge((p) => ({ ...p, active_until: e.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleSave} disabled={loading}>
|
||||
{loading ? "Saving…" : "Save Badge"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Always show early_access even before DB row exists ───────────────────────
|
||||
|
||||
const DEFAULT_SYSTEM_BADGES = [
|
||||
{ key: "early_access", label: "Early Access", description: "", information: "", asset: null, active_from: null, active_until: null },
|
||||
];
|
||||
|
||||
// ─── Inner page ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SystemBadgesInner() {
|
||||
const { fetchSystemBadges, systemBadges, loading } = useAdminTierPolicies();
|
||||
|
||||
useEffect(() => { fetchSystemBadges(); }, []);
|
||||
|
||||
const merged = DEFAULT_SYSTEM_BADGES.map((def) => {
|
||||
const fetched = systemBadges.find((b) => b.key === def.key);
|
||||
return fetched ?? def;
|
||||
});
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Plans", to: "/admin/tiers/plans" },
|
||||
{ label: "System Badges" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="System Badges - 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={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold">System Badges</h1>
|
||||
<p className="text-sm text-muted-foreground">Special badges awarded outside of subscription plans.</p>
|
||||
</div>
|
||||
|
||||
{loading && systemBadges.length === 0 ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => <Skeleton key={i} className="h-64 w-full rounded-lg" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{merged.map((badge) => (
|
||||
<SystemBadgeCard key={badge.key} badge={badge} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemBadges() {
|
||||
return (
|
||||
<AssetsProvider>
|
||||
<AdminTierPoliciesProvider>
|
||||
<SystemBadgesInner />
|
||||
</AdminTierPoliciesProvider>
|
||||
</AssetsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Pencil, Trash2, ShieldCheck, Lock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import {
|
||||
AdminTierCategoriesProvider,
|
||||
useAdminTierCategories,
|
||||
} from "@/contexts/AdminTierCategoriesContext";
|
||||
|
||||
function CategoryCard({ cat, onEdit, onDelete }) {
|
||||
const badge = cat.badgeAsset;
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-lg border bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{badge?.file_url ? (
|
||||
<img src={badge.file_url} alt={badge.display_name} className="w-10 h-10 object-contain" />
|
||||
) : (
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-semibold">{cat.name}</p>
|
||||
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{cat.slug}</code>
|
||||
<Badge variant="outline" className="text-[10px]">rank {cat.rank}</Badge>
|
||||
{!cat.is_active && <Badge variant="secondary">Inactive</Badge>}
|
||||
{cat.is_default && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Lock className="h-2.5 w-2.5" /> Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{cat.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{cat.description}</p>
|
||||
)}
|
||||
{cat.badge_label && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Badge label: <span className="font-medium text-foreground">{cat.badge_label}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(cat)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{!cat.is_default && (
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(cat)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierCategoriesInner() {
|
||||
const navigate = useNavigate();
|
||||
const { categories, loading, fetchCategories, deleteCategory } = useAdminTierCategories();
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => { fetchCategories(); }, []);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
await deleteCategory(deleteTarget.tier_category_id);
|
||||
setDeleting(false);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Tier Categories - 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: "Tiers", to: "/admin/tiers/plans" },
|
||||
{ label: "Tier Categories" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Tier Categories</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Define the tier levels available on the platform. Plans are built under each category.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => navigate("/admin/tiers/categories/add")}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Category
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
|
||||
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The <strong>Free</strong> category is the platform default and cannot be deleted or deactivated.
|
||||
All new users start here automatically. You can still configure its badge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-5" />
|
||||
|
||||
{loading && !categories.length ? (
|
||||
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
|
||||
) : !categories.length ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">No tier categories found.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{categories.map((cat) => (
|
||||
<CategoryCard
|
||||
key={cat.tier_category_id}
|
||||
cat={cat}
|
||||
onEdit={(c) => navigate(`/admin/tiers/categories/${c.tier_category_id}/edit`)}
|
||||
onDelete={(c) => setDeleteTarget(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Tier Category</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{deleteTarget?.name}</span>?
|
||||
This action cannot be undone. Any plans linked to this category must be reassigned first.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
|
||||
{deleting && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TierCategories() {
|
||||
return (
|
||||
<AdminTierCategoriesProvider>
|
||||
<TierCategoriesInner />
|
||||
</AdminTierCategoriesProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, House, ShieldPlus, ShieldOff, BadgeCheck } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -19,9 +21,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
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 }) {
|
||||
@@ -50,20 +52,46 @@ export default function UserTierList() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { userTiers, plans, loading, fetchUserTiers, fetchPlans, grantTier, revokeTier } = useTiers();
|
||||
const { fmtDate, fmtDateTime } = useDateFormat();
|
||||
|
||||
const [grantOpen, setGrantOpen] = useState(false);
|
||||
const [grantOpen, setGrantOpen] = useState(false);
|
||||
const [revokeTarget, setRevokeTarget] = useState(null);
|
||||
const [grantForm, setGrantForm] = useState({ tier: "premium", plan_id: "", notes: "" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [grantForm, setGrantForm] = useState({ tier: "", plan_id: "", notes: "" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
|
||||
const tierMap = useMemo(() => {
|
||||
const m = {};
|
||||
tierCategories.forEach((c) => { m[c.slug] = c; });
|
||||
return m;
|
||||
}, [tierCategories]);
|
||||
|
||||
const grantableCategories = useMemo(
|
||||
() => tierCategories.filter((c) => !c.is_default && c.is_active),
|
||||
[tierCategories]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserTiers(userId);
|
||||
fetchPlans();
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => {
|
||||
const all = data.data ?? [];
|
||||
setTierCategories(all);
|
||||
const grantable = all.filter((c) => !c.is_default && c.is_active);
|
||||
if (grantable.length > 0) setGrantForm((p) => ({ ...p, tier: grantable[0].slug }));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [userId]);
|
||||
|
||||
const activePlans = plans.filter((p) => p.is_active);
|
||||
const activePlans = plans.filter((p) => p.is_active);
|
||||
const filteredPlans = activePlans.filter((p) => p.tier === grantForm.tier);
|
||||
|
||||
const tierBadge = (slug) => {
|
||||
const { cls, label } = resolveTierBadge(slug, tierMap);
|
||||
return <Badge className={`${cls} capitalize`}>{label}</Badge>;
|
||||
};
|
||||
|
||||
const handleGrant = async () => {
|
||||
if (!grantForm.plan_id) return;
|
||||
setSubmitting(true);
|
||||
@@ -118,12 +146,10 @@ export default function UserTierList() {
|
||||
<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>
|
||||
<span className="text-sm px-3 py-0.5">{tierBadge(activeTier.tier)}</span>
|
||||
{activeTier.expires_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Expires {new Date(activeTier.expires_at).toLocaleDateString()}
|
||||
Expires {fmtDate(activeTier.expires_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -152,17 +178,17 @@ export default function UserTierList() {
|
||||
<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>
|
||||
{tierBadge(t.tier)}
|
||||
<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="Starts At">{t.starts_at ? fmtDateTime(t.starts_at) : "—"}</InfoRow>
|
||||
<InfoRow label="Expires At">{t.expires_at ? fmtDateTime(t.expires_at) : "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>
|
||||
<InfoRow label="Revoked At">{fmtDateTime(t.revoked_at)}</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
{t.notes && <p className="text-xs text-muted-foreground italic">{t.notes}</p>}
|
||||
@@ -191,8 +217,9 @@ export default function UserTierList() {
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="exclusive">Exclusive</SelectItem>
|
||||
{grantableCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, CreditCard, BadgeCheck, User } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -7,7 +7,10 @@ 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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
const STATUS_BADGE = {
|
||||
pending: "secondary",
|
||||
@@ -17,7 +20,6 @@ const STATUS_BADGE = {
|
||||
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 = {
|
||||
@@ -102,7 +104,7 @@ function ProviderReference({ payment }) {
|
||||
{/* Cancelled info — shown for any provider */}
|
||||
{payload.cancelled_at && (
|
||||
<InfoRow label="Cancelled At">
|
||||
{new Date(payload.cancelled_at).toLocaleString()}
|
||||
{fmtDateTime(payload.cancelled_at)}
|
||||
</InfoRow>
|
||||
)}
|
||||
|
||||
@@ -117,8 +119,15 @@ export default function ViewPayment() {
|
||||
const navigate = useNavigate();
|
||||
const { paymentId } = useParams();
|
||||
const { fetchPayment, payment, loading } = useTiers();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
useEffect(() => { fetchPayment(paymentId); }, [fetchPayment, paymentId]);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPayment(paymentId);
|
||||
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
|
||||
}, [paymentId]);
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
@@ -174,10 +183,10 @@ export default function ViewPayment() {
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow label="Paid At">
|
||||
{payment.paid_at ? new Date(payment.paid_at).toLocaleString() : "—"}
|
||||
{payment.paid_at ? fmtDateTime(payment.paid_at) : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Created At">
|
||||
{payment.createdAt ? new Date(payment.createdAt).toLocaleString() : "—"}
|
||||
{payment.createdAt ? fmtDateTime(payment.createdAt) : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
@@ -196,9 +205,7 @@ export default function ViewPayment() {
|
||||
<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>
|
||||
{(() => { const { cls, label } = resolveTierBadge(payment.plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
|
||||
</InfoRow>
|
||||
<InfoRow label="Duration">{payment.plan.duration_days} days</InfoRow>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, BookOpen } from "lucide-react";
|
||||
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, ShieldCheck } 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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
const TIER_BADGE = { premium: "default", exclusive: "destructive" };
|
||||
const STATUS_BADGE = { true: "default", false: "secondary" };
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
@@ -48,11 +50,15 @@ function LoadingSkeleton() {
|
||||
export default function ViewPlan() {
|
||||
const navigate = useNavigate();
|
||||
const { planId } = useParams();
|
||||
const { fetchPlan, fetchPlanCourses, planCourses, plan, loading } = useTiers();
|
||||
const { fetchPlan, plan, loading } = useTiers();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
fetchPlanCourses(planId);
|
||||
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
|
||||
}, [planId]);
|
||||
|
||||
return (
|
||||
@@ -79,15 +85,26 @@ export default function ViewPlan() {
|
||||
<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 className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/tiers/plans/${planId}/policy`)}
|
||||
disabled={loading}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||
Policy
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{loading && !plan ? (
|
||||
@@ -101,9 +118,7 @@ export default function ViewPlan() {
|
||||
<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>
|
||||
{(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
|
||||
</InfoRow>
|
||||
<InfoRow label="Duration">{plan.duration_days} days</InfoRow>
|
||||
<InfoRow label="Price">
|
||||
@@ -121,37 +136,14 @@ export default function ViewPlan() {
|
||||
<SectionCard icon={BadgeCheck} title="Audit">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Created At">
|
||||
{plan.createdAt ? new Date(plan.createdAt).toLocaleString() : "—"}
|
||||
{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Updated At">
|
||||
{plan.updatedAt ? new Date(plan.updatedAt).toLocaleString() : "—"}
|
||||
{plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}
|
||||
</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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useMemo, useState, useEffect, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { House, Users, QrCode, Download, Copy, Check, Link } from "lucide-react";
|
||||
import { House, Users, QrCode, Download, Copy, Check, Link, UserCheck, Search, X } from "lucide-react";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
@@ -16,9 +16,21 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useUserGroups } from "@/contexts/AdminUserGroupContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "../../config/user_groups/view/columns.config";
|
||||
import { buildToolbarActions } from "../../config/user_groups/view/toolbar.config";
|
||||
@@ -118,6 +130,142 @@ function InviteLinkDialog({ open, onOpenChange, group }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Assign Group Dialog ──────────────────────────────────────────────────────
|
||||
function AssignGroupDialog({ open, onOpenChange, userCount, groups, loading, onAssign }) {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedGroupId(null);
|
||||
setSearch('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const realGroups = groups.filter((g) => (g.group_code ?? '') !== 'NOGRP' && g.is_active);
|
||||
|
||||
const filtered = realGroups.filter((g) => {
|
||||
if (!search.trim()) return true;
|
||||
const q = search.toLowerCase();
|
||||
return g.name.toLowerCase().includes(q) || (g.group_code ?? '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserCheck className="size-4" /> Assign to Group
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a group to assign{" "}
|
||||
{userCount === 1 ? "this user" : `${userCount} users`} to.
|
||||
They will be added to the selected group.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Command shouldFilter={false} className="rounded-lg border shadow-sm">
|
||||
|
||||
{/* ── Search input ── */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search groups..."
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-0.5"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Group list ── */}
|
||||
<CommandList>
|
||||
<ScrollArea className="h-64">
|
||||
{filtered.length === 0 ? (
|
||||
<CommandEmpty className="py-8 text-sm text-center text-muted-foreground">
|
||||
{search ? `No results for "${search}".` : 'No groups available.'}
|
||||
</CommandEmpty>
|
||||
) : (
|
||||
<CommandGroup>
|
||||
{filtered.map((g) => {
|
||||
const isSelected = selectedGroupId === g.group_id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={g.group_id}
|
||||
value={String(g.group_id)}
|
||||
onSelect={() => setSelectedGroupId(isSelected ? null : g.group_id)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
|
||||
"aria-selected:bg-transparent data-selected:bg-transparent",
|
||||
isSelected
|
||||
? "bg-primary/[0.06] hover:bg-primary/[0.10]"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{/* Embossed checkbox */}
|
||||
<span className={cn(
|
||||
"h-4 w-4 shrink-0 rounded border-2 flex items-center justify-center transition-all",
|
||||
isSelected
|
||||
? "bg-primary border-primary shadow-none ring-2 ring-primary/25 ring-offset-1"
|
||||
: [
|
||||
"bg-background border-border",
|
||||
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.10),inset_0_1px_2px_rgba(0,0,0,0.06)]",
|
||||
"hover:border-primary/60",
|
||||
]
|
||||
)}>
|
||||
{isSelected && (
|
||||
<Check className="h-2.5 w-2.5 text-primary-foreground stroke-[3.5]" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Name */}
|
||||
<span className={cn(
|
||||
"flex-1 text-sm truncate",
|
||||
isSelected ? "font-medium text-foreground" : "text-foreground/90"
|
||||
)}>
|
||||
{g.name}
|
||||
</span>
|
||||
|
||||
{/* Group code pill */}
|
||||
<span className={cn(
|
||||
"font-mono text-[11px] px-1.5 py-0.5 rounded shrink-0",
|
||||
isSelected
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{g.group_code}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
|
||||
</Command>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!selectedGroupId || loading}
|
||||
onClick={() => onAssign(selectedGroupId)}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function ViewGroup() {
|
||||
const { groupId } = useParams();
|
||||
@@ -135,13 +283,19 @@ export default function ViewGroup() {
|
||||
const [archiveIds, setArchiveIds] = useState(null);
|
||||
const [memberAttrs, setMemberAttrs] = useState([]);
|
||||
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
const [assignTarget, setAssignTarget] = useState(null); // single row
|
||||
const [assignIds, setAssignIds] = useState(null); // bulk ids[]
|
||||
|
||||
const {
|
||||
group,
|
||||
groups,
|
||||
members,
|
||||
usersNotIn,
|
||||
pagination,
|
||||
setPagination,
|
||||
loading,
|
||||
fetchGroups,
|
||||
fetchGroup,
|
||||
fetchGroupFieldValues,
|
||||
fetchUsersNotInGroup,
|
||||
@@ -149,6 +303,8 @@ export default function ViewGroup() {
|
||||
removeUsersFromGroup,
|
||||
} = useUserGroups();
|
||||
|
||||
const isNoGroup = group?.group_code === 'NOGRP';
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
fetchGroup(groupId).then((res) => {
|
||||
@@ -169,7 +325,9 @@ export default function ViewGroup() {
|
||||
};
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
onRemove: (row) => setArchiveTarget(row),
|
||||
onRemove: (row) => setArchiveTarget(row),
|
||||
onAssign: (row) => openAssignDialog(row),
|
||||
isNoGroup,
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
@@ -184,13 +342,15 @@ export default function ViewGroup() {
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
onRemoveMember: (row) => setArchiveTarget(row),
|
||||
onRemoveMembers: (ids) => setArchiveIds(ids),
|
||||
onRemoveMember: (row) => setArchiveTarget(row),
|
||||
onRemoveMembers: (ids) => setArchiveIds(ids),
|
||||
onAssignMembers: (ids) => openAssignDialog(ids),
|
||||
isNoGroup,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(memberAttrs, rowActions),
|
||||
[memberAttrs],
|
||||
[memberAttrs, isNoGroup],
|
||||
);
|
||||
|
||||
const handleRemoveSuccess = () => {
|
||||
@@ -200,23 +360,38 @@ export default function ViewGroup() {
|
||||
fetchGroup(groupId, { page: 1, limit: pagination.limit });
|
||||
};
|
||||
|
||||
const openAssignDialog = (rowOrIds) => {
|
||||
fetchGroups({ limit: 100 });
|
||||
if (Array.isArray(rowOrIds)) {
|
||||
setAssignIds(rowOrIds);
|
||||
setAssignTarget(null);
|
||||
} else {
|
||||
setAssignTarget(rowOrIds);
|
||||
setAssignIds(null);
|
||||
}
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
const handleAssign = async (targetGroupId) => {
|
||||
const ids = assignIds ?? [assignTarget?.user_id];
|
||||
await addUsersToGroup(targetGroupId, ids);
|
||||
setAssignOpen(false);
|
||||
setAssignTarget(null);
|
||||
setAssignIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchGroup(groupId, { page: 1, limit: pagination.limit });
|
||||
};
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "User Groups", to: "/admin/groups" },
|
||||
{ label: group?.name ?? "View Group" },
|
||||
];
|
||||
|
||||
const formattedCreated = group?.createdAt
|
||||
? new Date(group.createdAt).toLocaleDateString("en-PH", {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
})
|
||||
: "—";
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const formattedUpdated = group?.updatedAt
|
||||
? new Date(group.updatedAt).toLocaleDateString("en-PH", {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
})
|
||||
: "—";
|
||||
const formattedCreated = group?.createdAt ? fmtDate(group.createdAt) : "—";
|
||||
const formattedUpdated = group?.updatedAt ? fmtDate(group.updatedAt) : "—";
|
||||
|
||||
const handleFetch = useCallback(
|
||||
(params) => fetchGroup(groupId, params),
|
||||
@@ -256,8 +431,8 @@ export default function ViewGroup() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Generate invite link button ── */}
|
||||
{group?.group_code && (
|
||||
{/* ── Generate invite link button — hidden for NOGRP (system default group) ── */}
|
||||
{group?.group_code && group.group_code !== 'NOGRP' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -330,6 +505,16 @@ export default function ViewGroup() {
|
||||
group={group}
|
||||
/>
|
||||
|
||||
{/* ── Assign to group dialog (NOGRP members only) ───────────────────── */}
|
||||
<AssignGroupDialog
|
||||
open={assignOpen}
|
||||
onOpenChange={setAssignOpen}
|
||||
userCount={assignIds?.length ?? (assignTarget ? 1 : 0)}
|
||||
groups={groups}
|
||||
loading={loading}
|
||||
onAssign={handleAssign}
|
||||
/>
|
||||
|
||||
{/* ── Add member ───────────────────────────────────────────────────── */}
|
||||
<AddSheet
|
||||
open={addMemberOpen}
|
||||
@@ -341,6 +526,7 @@ export default function ViewGroup() {
|
||||
onFetch={() => fetchUsersNotInGroup(groupId)}
|
||||
idKey="user_id"
|
||||
labelKey="full_name"
|
||||
warningKey="current_group"
|
||||
onSubmit={async (user_ids) => {
|
||||
await addUsersToGroup(groupId, user_ids);
|
||||
fetchGroup(groupId, { page: 1, limit: pagination.limit });
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
// ─── pages/users/ViewUser.jsx ─────────────────────────────────────────────────
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useUsers } from "@/contexts/AdminUserContext";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ArrowLeft, Trophy, Award, BadgeCheck, Activity, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { House, Trophy, Award, BadgeCheck, Activity, ChevronLeft, ChevronRight, ShieldBan, ShieldCheck } 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";
|
||||
import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog";
|
||||
import { UnbanDialog } from "@/components/generic/Dialogs/UnbanDialog";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
const StatusBadge = ({ value }) => (
|
||||
@@ -26,18 +31,24 @@ const ACHIEVEMENT_ICON = { badge: BadgeCheck, milestone: Trophy };
|
||||
export default function ViewUser() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDate, fmtDateTime } = useDateFormat();
|
||||
const {
|
||||
user, fetchUser, loading,
|
||||
achievements, achievementsLoading, fetchUserAchievements,
|
||||
activity, activityPagination, activityLoading, fetchUserActivity,
|
||||
bans, bansLoading, fetchUserBans,
|
||||
banUser, unbanUser,
|
||||
} = useUsers();
|
||||
|
||||
const [activityPage, setActivityPage] = useState(1);
|
||||
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
||||
const [unbanDialogOpen, setUnbanDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser(userId);
|
||||
fetchUserAchievements(userId);
|
||||
fetchUserActivity(userId, { page: 1, limit: 10 });
|
||||
fetchUserBans(userId);
|
||||
}, [userId]);
|
||||
|
||||
const loadActivityPage = (p) => {
|
||||
@@ -64,19 +75,56 @@ export default function ViewUser() {
|
||||
return (
|
||||
<div className="lg:container lg:mx-auto px-4 py-6 flex flex-col gap-6">
|
||||
|
||||
{/* ─── Breadcrumb ──────────────────────────────────────────────────── */}
|
||||
<AppBreadcrumb items={[
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Users", to: "/admin/users" },
|
||||
{ label: name.full_name ?? user.email ?? "View User" },
|
||||
]} />
|
||||
|
||||
{/* ─── Header ──────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users`)}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<Avatar className="size-11 shrink-0">
|
||||
<AvatarImage src={user.personal_info?.avatar?.url ?? undefined} alt={name.full_name ?? user.email} />
|
||||
<AvatarFallback className="text-sm font-semibold">
|
||||
{(name.full_name ?? user.email ?? "?")
|
||||
.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{name.full_name ?? "—"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{name.full_name ?? "—"}
|
||||
</h1>
|
||||
{user.is_banned && (
|
||||
<Badge variant="outline" className="text-xs text-destructive border-destructive/50 bg-destructive/5">
|
||||
<ShieldBan className="size-3 mr-1" /> Banned
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{user.is_banned ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
onClick={() => setUnbanDialogOpen(true)}
|
||||
>
|
||||
<ShieldCheck className="size-4 mr-1.5" /> Unban User
|
||||
</Button>
|
||||
) : user.is_active ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setBanDialogOpen(true)}
|
||||
>
|
||||
<ShieldBan className="size-4 mr-1.5" /> Ban User
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Account Info ────────────────────────────────────────────────── */}
|
||||
@@ -87,12 +135,20 @@ export default function ViewUser() {
|
||||
<Field label="Status">
|
||||
<StatusBadge value={user.is_active ? "Active" : "Not Active"} />
|
||||
</Field>
|
||||
<Field label="Ban Status">
|
||||
<StatusBadge value={user.is_banned ? "Banned" : "Not Banned"} />
|
||||
</Field>
|
||||
<Field label="Verified">
|
||||
<StatusBadge value={user.is_verified ? "Verified" : "Not Verified"} />
|
||||
</Field>
|
||||
<Field label="Registration Type">
|
||||
<StatusBadge value={user.reg_type} />
|
||||
</Field>
|
||||
{user.ban_expires_at && (
|
||||
<Field label="Ban Expires">
|
||||
{fmtDateTime(user.ban_expires_at)}
|
||||
</Field>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ─── Personal Info ───────────────────────────────────────────────── */}
|
||||
@@ -169,7 +225,7 @@ export default function ViewUser() {
|
||||
<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" })}
|
||||
{fmtDate(a.granted_at)}
|
||||
</p>
|
||||
</div>
|
||||
{isCert && (
|
||||
@@ -244,10 +300,7 @@ export default function ViewUser() {
|
||||
</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",
|
||||
})}
|
||||
{fmtDateTime(row.created_at)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -283,13 +336,106 @@ export default function ViewUser() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Ban History ─────────────────────────────────────────────────── */}
|
||||
<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">
|
||||
<ShieldBan className="size-4" /> Ban History
|
||||
{bans.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto font-normal">
|
||||
{bans.length} record{bans.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{bansLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-24 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : bans.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No bans on record.</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border -mx-6">
|
||||
{bans.map((ban) => (
|
||||
<div key={ban.ban_id} className="flex flex-col gap-1 px-6 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border shrink-0 ${
|
||||
ban.is_lifted
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-300 dark:bg-emerald-900/20 dark:text-emerald-400"
|
||||
: "bg-destructive/10 text-destructive border-destructive/30"
|
||||
}`}>
|
||||
{ban.is_lifted ? "Lifted" : ban.ban_type === "permanent" ? "Permanent" : "Temporary"}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate flex-1">{ban.reason}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-auto">
|
||||
{fmtDate(ban.banned_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Banned by{" "}
|
||||
<span className="text-foreground font-medium">
|
||||
{ban.banner?.personal_info?.name?.full_name ?? ban.banner?.email ?? "—"}
|
||||
</span>
|
||||
</span>
|
||||
{ban.ban_type === "temporary" && ban.expires_at && (
|
||||
<span>
|
||||
Until{" "}
|
||||
<span className="text-foreground">
|
||||
{fmtDate(ban.expires_at)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{ban.is_lifted && ban.lifter && (
|
||||
<span>
|
||||
Lifted by{" "}
|
||||
<span className="text-foreground font-medium">
|
||||
{ban.lifter?.personal_info?.name?.full_name ?? ban.lifter?.email ?? "—"}
|
||||
</span>
|
||||
{ban.lift_reason && <> — {ban.lift_reason}</>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Audit ───────────────────────────────────────────────────────── */}
|
||||
<Section title="Audit Trail">
|
||||
<Field label="Created At">{user.createdAt ? new Date(user.createdAt).toLocaleString() : "—"}</Field>
|
||||
<Field label="Updated At">{user.updatedAt ? new Date(user.updatedAt).toLocaleString() : "—"}</Field>
|
||||
<Field label="Deleted At">{user.deletedAt ? new Date(user.deletedAt).toLocaleString() : "—"}</Field>
|
||||
<Field label="Created At">{fmtDateTime(user.createdAt)}</Field>
|
||||
<Field label="Updated At">{fmtDateTime(user.updatedAt)}</Field>
|
||||
<Field label="Deleted At">{fmtDateTime(user.deletedAt)}</Field>
|
||||
</Section>
|
||||
|
||||
{/* ─── Ban / Unban Dialogs ─────────────────────────────────────────── */}
|
||||
<BanUserDialog
|
||||
open={banDialogOpen}
|
||||
onOpenChange={setBanDialogOpen}
|
||||
entity={user}
|
||||
entityLabel="User"
|
||||
getName={(u) => u?.personal_info?.name?.full_name ?? u?.email}
|
||||
onBan={(payload) => banUser(userId, payload)}
|
||||
loading={loading}
|
||||
onSuccess={() => { fetchUser(userId); fetchUserBans(userId); }}
|
||||
/>
|
||||
<UnbanDialog
|
||||
open={unbanDialogOpen}
|
||||
onOpenChange={setUnbanDialogOpen}
|
||||
entity={user}
|
||||
entityLabel="User"
|
||||
getName={(u) => u?.personal_info?.name?.full_name ?? u?.email}
|
||||
onUnban={(payload) => unbanUser(userId, payload)}
|
||||
loading={loading}
|
||||
onSuccess={() => { fetchUser(userId); fetchUserBans(userId); }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ 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 ModifyQuiz from '../pages/courses/units/ModifyQuiz'
|
||||
import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
|
||||
|
||||
// Task List
|
||||
@@ -81,13 +81,16 @@ 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 PlanList from '../pages/tiers/PlanList';
|
||||
import AddPlan from '../pages/tiers/AddPlan';
|
||||
import ViewPlan from '../pages/tiers/ViewPlan';
|
||||
import EditPlan from '../pages/tiers/EditPlan';
|
||||
import SystemBadges from '../pages/tiers/SystemBadges';
|
||||
import UserTierList from '../pages/tiers/UserTierList';
|
||||
import PaymentList from '../pages/tiers/PaymentList';
|
||||
import ViewPayment from '../pages/tiers/ViewPayment';
|
||||
import TierCategories from '../pages/tiers/TierCategories';
|
||||
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
|
||||
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
|
||||
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
|
||||
|
||||
@@ -123,6 +126,7 @@ export const AdminRoutes = {
|
||||
{ path: 'add/staff', element: <AddUser /> },
|
||||
{ path: 'view/:userId', element: <ViewUser /> },
|
||||
{ path: 'archived', element: <ArchivedUserList /> },
|
||||
{ path: ':userId/activity', element: <UserActivityPage /> },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -187,7 +191,7 @@ export const AdminRoutes = {
|
||||
{ path: 'add', element: <AddUnit /> },
|
||||
{ path: ':unitId/view', element: <ViewUnit /> },
|
||||
{ path: ':unitId/edit', element: <EditUnit /> },
|
||||
{ path: ":unitId/quiz", element: <UnitQuiz /> },
|
||||
{ path: ":unitId/quiz/edit", element: <ModifyQuiz /> },
|
||||
{ path: ":unitId/quiz/view", element: <ViewUnitQuiz /> },
|
||||
|
||||
// Lessons
|
||||
@@ -248,10 +252,20 @@ export const AdminRoutes = {
|
||||
children: [
|
||||
{ index: true, element: <PlanList /> },
|
||||
{ path: 'add', element: <AddPlan /> },
|
||||
{ path: ':planId/view', element: <ViewPlan /> },
|
||||
{ path: ':planId/edit', element: <EditPlan /> },
|
||||
{ path: ':planId/view', element: <ViewPlan /> },
|
||||
{ path: ':planId/edit', element: <EditPlan /> },
|
||||
]
|
||||
},
|
||||
{ path: 'system-badges', element: <SystemBadges /> },
|
||||
{
|
||||
path: 'categories',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <TierCategories /> },
|
||||
{ path: 'add', element: <AddTierCategory /> },
|
||||
{ path: ':id/edit', element: <EditTierCategory /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'users/:userId/tiers',
|
||||
element: <UserTierList />,
|
||||
@@ -283,7 +297,6 @@ export const AdminRoutes = {
|
||||
|
||||
// Activity Feed
|
||||
{ path: 'activity', element: <ActivityFeed /> },
|
||||
{ path: 'users/:userId/activity', element: <UserActivityPage /> },
|
||||
|
||||
// Add here
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user