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
+1 -1
View File
@@ -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": {
+56 -11
View File
@@ -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
@@ -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,7 +183,7 @@ 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 }))
@@ -192,6 +192,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
const [tierCategories, setTierCategories] = useState([]);
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 '—';
+129 -12
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.
*
* 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>;
}
}
}