add more things

This commit is contained in:
rgrgogu
2026-07-20 22:06:18 +08:00
parent f23b022a6f
commit 77e758c38b
14 changed files with 350 additions and 38 deletions
@@ -85,7 +85,7 @@ export function ColumnActionsDropdown({
{canFilter && (
<>
<DropdownMenuSeparator />
{canSort && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onFilterClick}>
Filter By
</DropdownMenuItem>
+10
View File
@@ -355,11 +355,20 @@ export function AdminTaskProvider({ children }) {
[request]
);
const warnPreCompleted = (warnings) => {
if (!warnings?.length) return;
const total = warnings.reduce((sum, w) => sum + w.completedCount, 0);
toast.warning(
`${total} assignee completion(s) already satisfy ${warnings.length} requirement(s) added — no action needed, they won't need to redo it.`
);
};
const createTask = useCallback(
(taskListId, payload) =>
request(async () => {
const res = await api.post(`${BASE}/${taskListId}/tasks`, payload);
toast('Task created.');
warnPreCompleted(res.data?.data?.warnings);
return res.data?.data ?? null;
}),
[request]
@@ -370,6 +379,7 @@ export function AdminTaskProvider({ children }) {
request(async () => {
const res = await api.patch(`${BASE}/${taskListId}/tasks/${taskId}`, payload);
toast('Task updated.');
warnPreCompleted(res.data?.data?.warnings);
return res.data?.data ?? null;
}),
[request]
@@ -20,6 +20,7 @@ import { buildSelectionActions } from "../../config/users/selection.config";
import { buildRowActions } from "../../config/users/rowActions.config";
import { USER_STAT_MAP } from "@/data/adminDashboard.data";
import { ROLE_CONFIG } from "@/data/profile.data";
import { getTimestamp } from "@/utils/timestamp.util";
export default function UsersTable() {
@@ -58,11 +59,17 @@ export default function UsersTable() {
tableRefsRef.current = refs; // ← just store directly, no override needed
};
const generatedBy = currentUser
? `${currentUser.personal_info?.name?.full_name ?? currentUser.email} (${ROLE_CONFIG[currentUser.acc_type]?.label ?? currentUser.acc_type})`
: undefined;
const exportConfig = {
allData: users,
attributes,
filename: `${getTimestamp()}_Users`,
sheetName: "Users",
title: "Users",
generatedBy,
};
const rowActions = buildRowActions({
@@ -250,6 +250,7 @@ export default function CreateTask() {
units={units}
lessons={lessons}
quizzes={quizzes}
taskListId={taskListId}
/>
{errors.requirements && (
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
@@ -246,6 +246,7 @@ export default function EditTask() {
units={units}
lessons={lessons}
quizzes={quizzes}
taskListId={taskListId}
/>
{errors.requirements && (
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, AlertTriangle, PenLine, ClipboardCheck, ShieldCheck, Link2, Unlink } from 'lucide-react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, AlertTriangle, PenLine, ClipboardCheck, ShieldCheck, Link2, Unlink, History } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -183,15 +183,16 @@ function createRequirement(type = 'visit_link') {
}
// ─── RequirementBuilder ───────────────────────────────────────────────────────
export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [], quizzes = [] }) {
export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [], quizzes = [], taskListId }) {
const [items, setItems] = useState(
value.length > 0
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
: []
);
const [tierCategories, setTierCategories] = useState([]);
const [lockedDialog, setLockedDialog] = useState(null); // { title, tierLabel, contentType }
const [noContentDialog, setNoContentDialog] = useState(null); // { title, contentType }
const [lockedDialog, setLockedDialog] = useState(null); // { title, tierLabel, contentType }
const [noContentDialog, setNoContentDialog] = useState(null); // { title, contentType }
const [preCompletedDialog, setPreCompletedDialog] = useState(null); // { title, contentType, completedCount, totalAssignees }
useEffect(() => {
api.get('/admin/tiers/categories')
@@ -224,6 +225,8 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
updateItem(key, { allowed_file_types: next });
};
const CONTENT_TYPE_TO_REQUIREMENT_TYPE = { course: 'read_course', unit: 'read_unit', lesson: 'read_lesson', quiz: 'pass_quiz' };
const handleContentSelect = (key, content, contentType) => {
updateItem(key, {
reference_id: content.uuid,
@@ -236,6 +239,30 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
const { rank, label } = resolveTierBadge(content.subscription ?? 'free', tierMap);
if (rank > 0) setLockedDialog({ title: content.title, tierLabel: label, contentType });
}
// Heads-up only — informs the admin that some assignees already finished
// this content before the requirement existed; task_progress auto-syncs
// it as done for them (task_reading_progress_sync.service.js), it doesn't
// need to be redone. Skipped entirely if there's no task list yet to check
// assignees against (e.g. TaskQueueStep, mid Create Task List wizard).
if (taskListId) {
const type = CONTENT_TYPE_TO_REQUIREMENT_TYPE[contentType];
api.get(`/admin/task-lists/${taskListId}/tasks/requirement-completion-check`, {
params: { type, reference_id: content.uuid, reference_label: content.title },
})
.then(({ data }) => {
const result = data?.data;
if (result?.completedCount > 0) {
setPreCompletedDialog({
title: content.title,
contentType,
completedCount: result.completedCount,
totalAssignees: result.totalAssignees,
});
}
})
.catch(() => { });
}
};
return (
@@ -604,6 +631,29 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Already-completed-by-assignees warning ─────────────────────── */}
<AlertDialog open={!!preCompletedDialog} onOpenChange={(v) => !v && setPreCompletedDialog(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<History className="size-4 text-amber-500" />
Already Completed by Some Assignees
</AlertDialogTitle>
<AlertDialogDescription className="space-y-1">
<span className="block font-medium text-foreground">{preCompletedDialog?.title}</span>
<span className="block">
{preCompletedDialog?.completedCount} of {preCompletedDialog?.totalAssignees} assigned user(s) already
completed this {preCompletedDialog?.contentType} before this requirement was added. It will
automatically count as done for them — they won't need to redo it.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setPreCompletedDialog(null)}>Got it</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -117,7 +117,11 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
const isFetching = fetching[course.reference_id];
// ── Locked card ───────────────────────────────────────────────────
if (isLocked) {
// Skipped when already completed — a completion recorded while the
// content was accessible still counts even if it re-locks later
// (task_progress/reading-progress rows are never revoked on a tier
// change), so it must keep showing as Completed, not Locked.
if (isLocked && !done) {
return (
<div
key={course.id}
@@ -85,7 +85,11 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
const isFetching = fetching[lesson.reference_id];
// ── Locked card ───────────────────────────────────────
if (isLocked) {
// Skipped when already completed — a completion recorded
// while the lesson was accessible still counts even if it
// re-locks later (reading-progress rows are never revoked
// on a tier change), so it must keep showing as Completed.
if (isLocked && !lesson.completed) {
return (
<div
key={lesson.id}
@@ -104,7 +104,11 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
const isFetching = fetching[unit.reference_id];
// ── Locked card ───────────────────────────────────
if (isLocked) {
// Skipped when already completed — a completion recorded
// while the unit was accessible still counts even if it
// re-locks later (reading-progress rows are never revoked
// on a tier change), so it must keep showing as Completed.
if (isLocked && !unit.completed) {
return (
<div
key={unit.id}
+59
View File
@@ -37,6 +37,65 @@ export function fmtDateTime(value, { timezone = 'local', locale } = {}) {
});
}
function utcOffsetLabel(date, timezone) {
if (timezone === 'UTC') return 'UTC+00:00';
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? '+' : '-';
const abs = Math.abs(offsetMinutes);
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
const mm = String(abs % 60).padStart(2, '0');
return `UTC${sign}${hh}:${mm}`;
}
/**
* "July 20, 2026, 9:45 PM (UTC+08:00)" — for report/export generation stamps.
* Date and time are formatted separately and joined with a literal ", " —
* combining them via a single toLocaleString call lets the runtime's ICU
* data pick the separator (some environments render "...2026 at 9:45 PM"),
* which would silently drift from this fixed report format.
*/
export function fmtDateTimeWithOffset(value, { timezone = 'local', locale } = {}) {
if (!value) return '—';
const d = new Date(value);
const datePart = d.toLocaleDateString(loc(locale), {
month: 'long', day: 'numeric', year: 'numeric',
...tzOpt(timezone),
});
const timePart = d.toLocaleTimeString(loc(locale), {
hour: 'numeric', minute: '2-digit',
...tzOpt(timezone),
});
return `${datePart}, ${timePart} (${utcOffsetLabel(d, timezone)})`;
}
/**
* ISO-8601 datetime shape, e.g. "2026-07-18T16:45:49.414Z" — matches raw
* timestamp strings coming straight off the API (created_at/updated_at,
* and any other datetime field) before they've been formatted for display.
*/
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
export function isIsoDateTimeString(value) {
return typeof value === 'string' && ISO_DATETIME_RE.test(value);
}
/** "26/07/20, 4:45 PM" — compact YY/MM/DD form for list/table/export views */
export function fmtDateTimeCompact(value, { timezone = 'local', locale } = {}) {
if (!value) return '—';
const d = new Date(value);
const parts = new Intl.DateTimeFormat('en-CA', {
year: '2-digit', month: '2-digit', day: '2-digit',
...tzOpt(timezone),
}).formatToParts(d);
const get = (type) => parts.find((p) => p.type === type)?.value;
const datePart = `${get('year')}/${get('month')}/${get('day')}`;
const timePart = d.toLocaleTimeString(loc(locale), {
hour: 'numeric', minute: '2-digit',
...tzOpt(timezone),
});
return `${datePart}, ${timePart}`;
}
/** "Jun 27" — no year, for compact table cells */
export function fmtDateShort(value, { timezone = 'local', locale } = {}) {
if (!value) return '—';
+134 -17
View File
@@ -1,8 +1,67 @@
// ─── utils/exportToExcel.js ───────────────────────────────────────────────────
import * as XLSX from "xlsx";
import XLSX from "xlsx-js-style";
import { BOOLEAN_FIELD_LABELS } from "@/utils/table.util";
import { isIsoDateTimeString, fmtDateTimeCompact, fmtDateTimeWithOffset } from "@/utils/datetime.util";
const SKIP_IDS = new Set(["select", "actions"]);
// ─── Banner styling shared by every export's title + header row ───────────────
const HEADER_FILL = "132440";
const HEADER_FONT_COLOR = "FFFFFF";
const TITLE_FILL = "D6DEEA";
const TITLE_FONT_COLOR = "132440";
function titleCell(text) {
return {
v: text ?? "",
t: "s",
s: {
font: { bold: true, sz: 14, color: { rgb: TITLE_FONT_COLOR } },
fill: { patternType: "solid", fgColor: { rgb: TITLE_FILL } },
alignment: { horizontal: "center", vertical: "center" },
},
};
}
function headerCell(text) {
return {
v: text ?? "",
t: "s",
s: {
font: { bold: true, color: { rgb: HEADER_FONT_COLOR } },
fill: { patternType: "solid", fgColor: { rgb: HEADER_FILL } },
alignment: { horizontal: "center", vertical: "center" },
},
};
}
// ─── Footer styling — generation stamp + confidentiality notice ───────────────
const FOOTER_TEXT_COLOR = "6B7280";
const FOOTER_NOTE_COLOR = "B91C1C";
function footerCell(text, { bold = false, color = FOOTER_TEXT_COLOR } = {}) {
return {
v: text ?? "",
t: "s",
s: {
font: { italic: !bold, bold, sz: bold ? 10 : 9, color: { rgb: color } },
alignment: { horizontal: "left", vertical: "center" },
},
};
}
// Each entry is either null (blank spacer row) or { text, bold?, color? }.
function buildFooterLines({ generatedBy, recordCount, timezone, footerNote }) {
const lines = [
null,
{ text: `Generated on: ${fmtDateTimeWithOffset(new Date(), { timezone })}` },
];
if (generatedBy) lines.push({ text: `Generated by: ${generatedBy}` });
lines.push(null, { text: `Records: ${recordCount}` });
if (footerNote) lines.push(null, { text: footerNote, bold: true, color: FOOTER_NOTE_COLOR });
return lines;
}
// ─── Flatten a value into something a spreadsheet cell can display ────────────
// Array-of-object columns (e.g. `groups`: [{group_id, name, group_code}]) would
// otherwise hit Array.prototype.toString → "[object Object]" per item.
@@ -21,7 +80,11 @@ function flattenForExport(value) {
}
// ─── Resolve dot-notation or direct key from a row ────────────────────────────
function resolveValue(row, key) {
// `boolLabels` — [falseLabel, trueLabel] from BOOLEAN_FIELD_LABELS, keyed by
// field name — so boolean-backed columns (e.g. is_active) export the same
// human-readable text ("Active"/"Not Active") shown in table badges/filters,
// instead of raw true/false.
function resolveValue(row, key, boolLabels) {
if (!key) return "";
// ─── Try direct key first e.g. "email", "acc_type" ────────────────────────
@@ -30,6 +93,17 @@ function resolveValue(row, key) {
// ─── Dot-notation fallback e.g. "personal_info.name.full_name" ──────────
: key.split(".").reduce((acc, part) => acc?.[part] ?? "", row);
if (boolLabels && raw !== null && raw !== undefined && raw !== "") {
return boolLabels[raw ? 1 : 0];
}
// Raw ISO timestamps (e.g. created_at/updated_at) never look right in a
// spreadsheet cell — format them the same "YY/MM/DD, h:mm a" way table
// cells do, so exports never leak an unformatted value either.
if (isIsoDateTimeString(raw)) {
return fmtDateTimeCompact(raw);
}
return flattenForExport(raw) ?? "";
}
@@ -39,7 +113,7 @@ function flattenRow(row, attributes) {
for (const attr of attributes) {
const { field } = attr;
result[field] = resolveValue(row, field);
result[field] = resolveValue(row, field, BOOLEAN_FIELD_LABELS[field]);
}
return result;
@@ -51,7 +125,7 @@ function flattenRow(row, attributes) {
*
* @param {TableInstance|null} tableInstance
* @param {Array} attributes
* @returns {Array} [{ key, label, width }]
* @returns {Array} [{ key, label, width, boolLabels }]
*/
function resolveColumns(tableInstance, attributes) {
if (tableInstance) {
@@ -64,6 +138,7 @@ function resolveColumns(tableInstance, attributes) {
col.columnDef.meta?.label ??
(typeof col.columnDef.header === "string" ? col.columnDef.header : col.id),
width: col.columnDef.meta?.exportWidth ?? 20,
boolLabels: BOOLEAN_FIELD_LABELS[col.id],
}));
}
@@ -73,6 +148,7 @@ function resolveColumns(tableInstance, attributes) {
key: attr.field,
label: attr.name ?? attr.field,
width: attr.exportWidth ?? 20,
boolLabels: BOOLEAN_FIELD_LABELS[attr.field],
}));
}
@@ -90,10 +166,14 @@ function resolveColumns(tableInstance, attributes) {
* @param {TableInstance|null}[options.tableInstance] TanStack table — reads visible columns
* @param {string} [options.filename] Without extension. Default: "export"
* @param {string} [options.sheetName] Sheet tab name. Default: "Sheet1"
* @param {string} [options.title] Banner title (row 1, merged). Default: sheetName
* @param {string} [options.generatedBy] e.g. "Kenneth Obsequio (Administrator)" — omitted from footer if not given
* @param {string} [options.timezone] 'local' | 'UTC', for the "Generated on" stamp. Default: 'local'
* @param {string|null} [options.footerNote] Confidentiality notice. Pass null to omit.
*
* @example
* // Toolbar export (all rows, visible columns)
* onClick: (table) => exportTableToExcel({ allData: users, attributes, tableInstance: table })
* onClick: (table) => exportTableToExcel({ allData: users, attributes, tableInstance: table, title: "📋 Users", generatedBy: "Kenneth Obsequio (Administrator)" })
*
* @example
* // Selection export (checked rows only, visible columns)
@@ -106,45 +186,82 @@ export function exportTableToExcel({
tableInstance = null,
filename = "export",
sheetName = "Sheet1",
title,
generatedBy,
timezone = "local",
footerNote = "Confidential – For authorized personnel only.",
} = {}) {
const columns = resolveColumns(tableInstance, attributes);
const data = Array.isArray(selectedRows) && selectedRows.length > 0
? selectedRows
: allData;
console.log(columns, data)
exportToExcel({ data, columns, filename, sheetName });
exportToExcel({
data, columns, filename, sheetName,
title: title ?? sheetName,
generatedBy, timezone, footerNote,
});
}
/**
* Core export — writes a plain array of objects to an .xlsx file.
* Use exportTableToExcel above for table-aware dynamic exports.
*
* @param {Object} options
* @param {Array} options.data Rows to export
* @param {Array} options.columns [{ key, label, width }]
* @param {string} [options.filename] Without extension. Default: "export"
* @param {string} [options.sheetName] Sheet tab name. Default: "Sheet1"
* Every sheet gets the same layout: a merged, centered title (row 1) above
* a styled column-header row (row 2), the data, then a footer block with
* a generation stamp, record count, and confidentiality notice — so exports
* look consistent across every admin table without each call site having
* to build it by hand.
*
* @param {Object} options
* @param {Array} options.data Rows to export
* @param {Array} options.columns [{ key, label, width }]
* @param {string} [options.filename] Without extension. Default: "export"
* @param {string} [options.sheetName] Sheet tab name. Default: "Sheet1"
* @param {string} [options.title] Banner title (row 1, merged). Default: sheetName
* @param {string} [options.generatedBy] e.g. "Kenneth Obsequio (Administrator)" — omitted from footer if not given
* @param {string} [options.timezone] 'local' | 'UTC', for the "Generated on" stamp. Default: 'local'
* @param {string|null}[options.footerNote] Confidentiality notice. Pass null to omit.
*/
export function exportToExcel({
data = [],
columns = [],
filename = "export",
sheetName = "Sheet1",
title,
generatedBy,
timezone = "local",
footerNote = "Confidential – For authorized personnel only.",
} = {}) {
if (!data.length || !columns.length) {
console.warn("exportToExcel: no data or columns to export.");
return;
}
const headers = columns.map((c) => c.label ?? c.key);
const rows = data.map((row) =>
columns.map((c) => resolveValue(row, c.key))
const titleRow = [titleCell(title ?? sheetName), ...Array(columns.length - 1).fill("")];
const headerRow = columns.map((c) => headerCell(c.label ?? c.key));
const dataRows = data.map((row) =>
columns.map((c) => resolveValue(row, c.key, c.boolLabels))
);
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
const footerLines = buildFooterLines({ generatedBy, recordCount: data.length, timezone, footerNote });
const footerRows = footerLines.map((line) =>
line
? [footerCell(line.text, line), ...Array(columns.length - 1).fill("")]
: Array(columns.length).fill("")
);
const ws = XLSX.utils.aoa_to_sheet([titleRow, headerRow, ...dataRows, ...footerRows]);
ws["!cols"] = columns.map((c) => ({ wch: c.width ?? 20 }));
ws["!rows"] = [{ hpt: 24 }];
const merges = [{ s: { r: 0, c: 0 }, e: { r: 0, c: columns.length - 1 } }];
footerLines.forEach((line, i) => {
if (!line) return;
const rowIdx = 2 + dataRows.length + i;
merges.push({ s: { r: rowIdx, c: 0 }, e: { r: rowIdx, c: columns.length - 1 } });
});
ws["!merges"] = merges;
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
+11 -1
View File
@@ -35,6 +35,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Search, ArrowUp, ArrowDown, ArrowUpDown, Eye } from "lucide-react";
import { isIsoDateTimeString, fmtDateTimeCompact } from "@/utils/datetime.util";
export const pageSizes = [10, 25, 50, 75, 100, 250, 500, 750, 1000]
@@ -129,7 +130,16 @@ function renderCell(attr, value) {
}
case "date": return <span className="text-xs text-muted-foreground">{formatDate(value)}</span>;
case "number": return <span className="font-mono text-xs font-semibold text-muted-foreground">{value}</span>;
default: return <span className="block truncate max-w-56 text-sm" title={String(value)}>{value}</span>;
default: {
// Server-reported attr.type doesn't always land on "date" for
// datetime fields (e.g. created_at/updated_at) — fall back to
// formatting anything that's shaped like a raw ISO timestamp so it
// never surfaces unformatted in a table cell.
if (isIsoDateTimeString(value)) {
return <span className="text-xs text-muted-foreground">{fmtDateTimeCompact(value)}</span>;
}
return <span className="block truncate max-w-56 text-sm" title={String(value)}>{value}</span>;
}
}
}