add: more things

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-04 03:35:22 +08:00
parent 72298b86cd
commit b0f40e5133
78 changed files with 2116 additions and 1621 deletions
@@ -0,0 +1,141 @@
// ─── components/PermanentDeleteDialog.jsx ────────────────────────────────────
import { useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
import { AlertTriangle } from "lucide-react";
/**
* Generic permanent-delete dialog — works for any entity (users, groups, etc.)
* Unlike ArchiveDialog, this is irreversible: there is no Restore after this.
*
* Single: <PermanentDeleteDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <PermanentDeleteDialog ids={[1, 2, 3]} entityLabel="Group" ... />
*
* @param {Function} onDelete (id | { ids }) => Promise — called with single id or { ids }
* @param {Function} getName (entity) => string — how to display the entity name
* @param {string} entityLabel e.g. "User", "Group"
* @param {boolean} loading from whichever context the parent uses
* @param {Function} onImpactCheck optional — async () => { label, count }[]
* called when dialog opens (single delete only).
* Returns an array of impact lines to warn about.
* Items with count === 0 are filtered out automatically.
*/
export function PermanentDeleteDialog({
open,
onOpenChange,
entity,
ids,
entityLabel = "Item",
getName,
onDelete,
loading,
onSuccess,
onImpactCheck,
}) {
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const [impactLoading, setImpactLoading] = useState(false);
const [impacts, setImpacts] = useState([]);
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.name ?? entity?.email ?? "this item");
// Fetch impact when dialog opens for a single-entity delete
useEffect(() => {
if (!open || isBulk || !onImpactCheck) {
setImpacts([]);
return;
}
setImpactLoading(true);
setImpacts([]);
onImpactCheck()
.then((rows) => setImpacts((rows ?? []).filter((r) => r.count > 0)))
.catch(() => setImpacts([]))
.finally(() => setImpactLoading(false));
}, [open]);
const handleDelete = async () => {
const res = isBulk
? await onDelete({ ids })
: await onDelete(entity);
if (res) {
onOpenChange(false);
onSuccess?.();
}
};
const hasImpact = impacts.length > 0;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Permanently Delete {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</AlertDialogTitle>
{impactLoading ? (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Spinner className="size-4" /> Checking impact…
</div>
) : (
<>
<AlertDialogDescription>
Are you sure you want to permanently delete{" "}
<span className="font-medium text-foreground">{displayName}</span>?{" "}
This action cannot be undone.
</AlertDialogDescription>
{hasImpact && (
<div className="mt-3 rounded-md border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-400 font-medium text-sm">
<AlertTriangle className="h-4 w-4 shrink-0" />
This will also permanently remove related data
</div>
<ul className="ml-5 list-disc text-sm text-amber-800 dark:text-amber-300 space-y-0.5">
{impacts.map((impact, i) => (
<li key={i}>
<span className="font-semibold">{impact.count}</span> {impact.label}
</li>
))}
</ul>
<p className="text-xs text-amber-700 dark:text-amber-400 pt-0.5">
Deleting anyway will permanently remove all of the above along with it.
</p>
</div>
)}
<div className="mt-3 rounded-md border border-destructive/40 bg-destructive/5 p-2.5 text-xs text-destructive">
This is different from Archive — there is no Restore after this.
</div>
</>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading || impactLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={loading || impactLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading && <Spinner className="size-4 mr-2" />}
Delete{isBulk ? ` ${count}` : ""} Permanently
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -1,31 +0,0 @@
const ROWS = [
{ md: "**bold**", out: "bold" },
{ md: "*italic*", out: "italic" },
{ md: "# Heading", out: "large heading (## and ### for smaller)" },
{ md: "[link text](https://…)", out: "a link" },
{ md: "- item", out: "bullet list" },
{ md: "1. item", out: "numbered list" },
{ md: "`code`", out: "inline code" },
{ md: "blank line", out: "starts a new paragraph" },
];
// Compact reference for the Markdown body editor — shown under the textarea
// wherever admins author Markdown that gets converted to HTML on save.
export function MarkdownCheatsheet() {
return (
<div className="rounded-md border bg-muted/40 p-3 text-xs">
<p className="font-medium text-foreground mb-2">Quick Markdown reference</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
{ROWS.map((r) => (
<div key={r.md} className="flex items-center gap-2 min-w-0">
<code className="bg-muted px-1.5 py-0.5 rounded shrink-0">{r.md}</code>
<span className="text-muted-foreground truncate">→ {r.out}</span>
</div>
))}
</div>
<p className="text-muted-foreground mt-2">
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens still work anywhere in the text.
</p>
</div>
);
}
@@ -1,98 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Send } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useAdminEmailBroadcasts } from "@/contexts/AdminEmailBroadcastContext";
// Reused from Notification Broadcasts so admins pick an audience the same way
// everywhere — same target types, same picker for task list / course / tier plan.
export function SendEmailBroadcastDialog({ open, onOpenChange, template }) {
const navigate = useNavigate();
const { createBroadcast, loading } = useAdminEmailBroadcasts();
const [targetType, setTargetType] = useState("");
const [targetId, setTargetId] = useState(null);
const [error, setError] = useState("");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const reset = () => { setTargetType(""); setTargetId(null); setError(""); };
const handleSend = async () => {
if (!targetType) return setError("Please select an audience.");
if (needsTarget && !targetId) return setError("Please select a specific target.");
setError("");
const result = await createBroadcast({
email_template_id: template.email_template_id,
target_type: targetType,
target_id: needsTarget ? targetId : null,
});
if (result) {
reset();
onOpenChange(false);
navigate("/admin/email-broadcasts");
}
};
return (
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) reset(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Send to Recipients</DialogTitle>
<DialogDescription>
Send <span className="font-semibold text-foreground">{template?.label}</span> as a real email.
Delivery is paced in the background — this won't block or time out.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Audience</Label>
<Select value={targetType} onValueChange={(v) => { setTargetType(v); setTargetId(null); }}>
<SelectTrigger><SelectValue placeholder="Select an audience" /></SelectTrigger>
<SelectContent>
{TARGET_TYPE_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
{targetType && (
<p className="text-xs text-muted-foreground">{TARGET_TYPE_MAP[targetType]?.description}</p>
)}
</div>
{needsTarget && (
<div className="space-y-1.5">
<Label>Target</Label>
<BroadcastTargetPicker targetType={targetType} value={targetId} onChange={setTargetId} />
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button onClick={handleSend} disabled={loading}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
Send
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+2 -1
View File
@@ -32,6 +32,7 @@ export default function DataTable({
pageSizeOptions = pageSizes,
columnPinning = { right: [], left: [] },
className = "",
enableRowSelection = true,
}) {
// ─── Refs — always hold latest filters and sort ───────────────────────────
const filtersRef = useRef([]);
@@ -114,7 +115,7 @@ export default function DataTable({
pageSize: pagination.limit,
},
},
enableRowSelection: true,
enableRowSelection,
onRowSelectionChange: setRowSelection,
manualPagination: true,
manualSorting: true,
@@ -8,7 +8,11 @@ import { Checkbox } from "@/components/ui/checkbox";
*
* The header renders a "select all on this page" checkbox with an
* indeterminate state when only some rows are checked.
* Each cell renders a per-row checkbox.
* Each cell renders a per-row checkbox, disabled for any row TanStack
* reports as non-selectable via `row.getCanSelect()` — controlled by
* passing a function to DataTable's `enableRowSelection` prop, e.g.
* `enableRowSelection={(row) => row.original.user_id !== currentUserId}`
* to prevent a user from selecting their own row.
*
* @returns {Object} TanStack ColumnDef
*
@@ -49,14 +53,18 @@ export function buildSelectionColumn() {
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center px-1">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
cell: ({ row }) => {
const canSelect = row.getCanSelect();
return (
<div className="flex items-center justify-center px-1">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
disabled={!canSelect}
aria-label={canSelect ? "Select row" : "Row cannot be selected"}
/>
</div>
);
},
};
}