mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user