diff --git a/package.json b/package.json
index aa8060e..8a0cdef 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,7 @@
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
- "xlsx": "^0.18.5",
+ "xlsx-js-style": "^1.2.0",
"zod": "^4.4.3"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ba6ef0e..3fb31fb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -128,9 +128,9 @@ importers:
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- xlsx:
- specifier: ^0.18.5
- version: 0.18.5
+ xlsx-js-style:
+ specifier: ^1.2.0
+ version: 1.2.0
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -1675,6 +1675,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ adler-32@1.2.0:
+ resolution: {integrity: sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==}
+ engines: {node: '>=0.8'}
+ hasBin: true
+
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
@@ -1847,9 +1852,10 @@ packages:
code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
- codepage@1.15.0:
- resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
+ codepage@1.14.0:
+ resolution: {integrity: sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==}
engines: {node: '>=0.8'}
+ hasBin: true
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
@@ -1873,6 +1879,12 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
+ commander@2.14.1:
+ resolution: {integrity: sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==}
+
+ commander@2.17.1:
+ resolution: {integrity: sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==}
+
concurrently@9.2.1:
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
engines: {node: '>=18'}
@@ -2226,6 +2238,10 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
+ exit-on-epipe@1.0.1:
+ resolution: {integrity: sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==}
+ engines: {node: '>=0.8'}
+
express-rate-limit@8.4.1:
resolution: {integrity: sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==}
engines: {node: '>= 16'}
@@ -2280,6 +2296,9 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
+ fflate@0.3.11:
+ resolution: {integrity: sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==}
+
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
@@ -3188,6 +3207,11 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
+ printj@1.1.2:
+ resolution: {integrity: sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==}
+ engines: {node: '>=0.8'}
+ hasBin: true
+
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@@ -3853,8 +3877,8 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
- xlsx@0.18.5:
- resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+ xlsx-js-style@1.2.0:
+ resolution: {integrity: sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==}
engines: {node: '>=0.8'}
hasBin: true
@@ -5401,6 +5425,11 @@ snapshots:
acorn@8.16.0: {}
+ adler-32@1.2.0:
+ dependencies:
+ exit-on-epipe: 1.0.1
+ printj: 1.1.2
+
adler-32@1.3.1: {}
agent-base@7.1.4: {}
@@ -5574,7 +5603,10 @@ snapshots:
code-block-writer@13.0.3: {}
- codepage@1.15.0: {}
+ codepage@1.14.0:
+ dependencies:
+ commander: 2.14.1
+ exit-on-epipe: 1.0.1
color-convert@2.0.1:
dependencies:
@@ -5592,6 +5624,10 @@ snapshots:
commander@14.0.3: {}
+ commander@2.14.1: {}
+
+ commander@2.17.1: {}
+
concurrently@9.2.1:
dependencies:
chalk: 4.1.2
@@ -5919,6 +5955,8 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
+ exit-on-epipe@1.0.1: {}
+
express-rate-limit@8.4.1(express@5.2.1):
dependencies:
express: 5.2.1
@@ -5998,6 +6036,8 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
+ fflate@0.3.11: {}
+
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
@@ -7007,6 +7047,8 @@ snapshots:
dependencies:
parse-ms: 4.0.0
+ printj@1.1.2: {}
+
prompts@2.4.2:
dependencies:
kleur: 3.0.3
@@ -7786,12 +7828,15 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
- xlsx@0.18.5:
+ xlsx-js-style@1.2.0:
dependencies:
- adler-32: 1.3.1
+ adler-32: 1.2.0
cfb: 1.2.2
- codepage: 1.15.0
+ codepage: 1.14.0
+ commander: 2.17.1
crc-32: 1.2.2
+ exit-on-epipe: 1.0.1
+ fflate: 0.3.11
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
diff --git a/src/components/generic/Table/ColumnActionsDropdown.jsx b/src/components/generic/Table/ColumnActionsDropdown.jsx
index bedb2c6..eb93457 100644
--- a/src/components/generic/Table/ColumnActionsDropdown.jsx
+++ b/src/components/generic/Table/ColumnActionsDropdown.jsx
@@ -85,7 +85,7 @@ export function ColumnActionsDropdown({
{canFilter && (
<>
-
+ {canSort && }
Filter By
diff --git a/src/contexts/AdminTaskContext.jsx b/src/contexts/AdminTaskContext.jsx
index 1753584..01b0bb5 100644
--- a/src/contexts/AdminTaskContext.jsx
+++ b/src/contexts/AdminTaskContext.jsx
@@ -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]
diff --git a/src/modules/admin/components/users/UserTable.jsx b/src/modules/admin/components/users/UserTable.jsx
index e3b6df1..3f2a5d7 100644
--- a/src/modules/admin/components/users/UserTable.jsx
+++ b/src/modules/admin/components/users/UserTable.jsx
@@ -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({
diff --git a/src/modules/admin/pages/task_list/task/CreateTask.jsx b/src/modules/admin/pages/task_list/task/CreateTask.jsx
index c910161..e17c2c8 100644
--- a/src/modules/admin/pages/task_list/task/CreateTask.jsx
+++ b/src/modules/admin/pages/task_list/task/CreateTask.jsx
@@ -250,6 +250,7 @@ export default function CreateTask() {
units={units}
lessons={lessons}
quizzes={quizzes}
+ taskListId={taskListId}
/>
{errors.requirements && (
{errors.requirements}
diff --git a/src/modules/admin/pages/task_list/task/EditTask.jsx b/src/modules/admin/pages/task_list/task/EditTask.jsx
index 2326c3a..169f878 100644
--- a/src/modules/admin/pages/task_list/task/EditTask.jsx
+++ b/src/modules/admin/pages/task_list/task/EditTask.jsx
@@ -246,6 +246,7 @@ export default function EditTask() {
units={units}
lessons={lessons}
quizzes={quizzes}
+ taskListId={taskListId}
/>
{errors.requirements && (
{errors.requirements}
diff --git a/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx b/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx
index b535841..be373f4 100644
--- a/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx
+++ b/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx
@@ -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 = [],
+
+ {/* ── Already-completed-by-assignees warning ─────────────────────── */}
+ !v && setPreCompletedDialog(null)}>
+
+
+
+
+ Already Completed by Some Assignees
+
+
+ {preCompletedDialog?.title}
+
+ {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.
+
+
+
+
+ setPreCompletedDialog(null)}>Got it
+
+
+
);
}
diff --git a/src/modules/client/components/blocks/ReadCourse.jsx b/src/modules/client/components/blocks/ReadCourse.jsx
index c1e3506..00f7e77 100644
--- a/src/modules/client/components/blocks/ReadCourse.jsx
+++ b/src/modules/client/components/blocks/ReadCourse.jsx
@@ -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 (
= 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 '—';
diff --git a/src/utils/excel.util.js b/src/utils/excel.util.js
index 24c9ec1..e7aaa1a 100644
--- a/src/utils/excel.util.js
+++ b/src/utils/excel.util.js
@@ -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);
diff --git a/src/utils/table.util.jsx b/src/utils/table.util.jsx
index 4707fd1..fba20da 100644
--- a/src/utils/table.util.jsx
+++ b/src/utils/table.util.jsx
@@ -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 {formatDate(value)};
case "number": return {value};
- default: return {value};
+ 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 {fmtDateTimeCompact(value)};
+ }
+ return {value};
+ }
}
}