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}; + } } }