mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add more things
This commit is contained in:
+1
-1
@@ -50,7 +50,7 @@
|
|||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx-js-style": "^1.2.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+56
-11
@@ -128,9 +128,9 @@ importers:
|
|||||||
vaul:
|
vaul:
|
||||||
specifier: ^1.1.2
|
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)
|
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:
|
xlsx-js-style:
|
||||||
specifier: ^0.18.5
|
specifier: ^1.2.0
|
||||||
version: 0.18.5
|
version: 1.2.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.4.3
|
specifier: ^4.4.3
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
@@ -1675,6 +1675,11 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
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:
|
adler-32@1.3.1:
|
||||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -1847,9 +1852,10 @@ packages:
|
|||||||
code-block-writer@13.0.3:
|
code-block-writer@13.0.3:
|
||||||
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
||||||
|
|
||||||
codepage@1.15.0:
|
codepage@1.14.0:
|
||||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
resolution: {integrity: sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
@@ -1873,6 +1879,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||||
engines: {node: '>=20'}
|
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:
|
concurrently@9.2.1:
|
||||||
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
|
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2226,6 +2238,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
||||||
engines: {node: ^18.19.0 || >=20.5.0}
|
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:
|
express-rate-limit@8.4.1:
|
||||||
resolution: {integrity: sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==}
|
resolution: {integrity: sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
@@ -2280,6 +2296,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||||
engines: {node: ^12.20 || >= 14.13}
|
engines: {node: ^12.20 || >= 14.13}
|
||||||
|
|
||||||
|
fflate@0.3.11:
|
||||||
|
resolution: {integrity: sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==}
|
||||||
|
|
||||||
figures@6.1.0:
|
figures@6.1.0:
|
||||||
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
|
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3188,6 +3207,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
||||||
engines: {node: '>=18'}
|
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:
|
prompts@2.4.2:
|
||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -3853,8 +3877,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
xlsx@0.18.5:
|
xlsx-js-style@1.2.0:
|
||||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
resolution: {integrity: sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -5401,6 +5425,11 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.16.0: {}
|
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: {}
|
adler-32@1.3.1: {}
|
||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
@@ -5574,7 +5603,10 @@ snapshots:
|
|||||||
|
|
||||||
code-block-writer@13.0.3: {}
|
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:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5592,6 +5624,10 @@ snapshots:
|
|||||||
|
|
||||||
commander@14.0.3: {}
|
commander@14.0.3: {}
|
||||||
|
|
||||||
|
commander@2.14.1: {}
|
||||||
|
|
||||||
|
commander@2.17.1: {}
|
||||||
|
|
||||||
concurrently@9.2.1:
|
concurrently@9.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
@@ -5919,6 +5955,8 @@ snapshots:
|
|||||||
strip-final-newline: 4.0.0
|
strip-final-newline: 4.0.0
|
||||||
yoctocolors: 2.1.2
|
yoctocolors: 2.1.2
|
||||||
|
|
||||||
|
exit-on-epipe@1.0.1: {}
|
||||||
|
|
||||||
express-rate-limit@8.4.1(express@5.2.1):
|
express-rate-limit@8.4.1(express@5.2.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
@@ -5998,6 +6036,8 @@ snapshots:
|
|||||||
node-domexception: 1.0.0
|
node-domexception: 1.0.0
|
||||||
web-streams-polyfill: 3.3.3
|
web-streams-polyfill: 3.3.3
|
||||||
|
|
||||||
|
fflate@0.3.11: {}
|
||||||
|
|
||||||
figures@6.1.0:
|
figures@6.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-unicode-supported: 2.1.0
|
is-unicode-supported: 2.1.0
|
||||||
@@ -7007,6 +7047,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
parse-ms: 4.0.0
|
parse-ms: 4.0.0
|
||||||
|
|
||||||
|
printj@1.1.2: {}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
@@ -7786,12 +7828,15 @@ snapshots:
|
|||||||
is-wsl: 3.1.1
|
is-wsl: 3.1.1
|
||||||
powershell-utils: 0.1.0
|
powershell-utils: 0.1.0
|
||||||
|
|
||||||
xlsx@0.18.5:
|
xlsx-js-style@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
adler-32: 1.3.1
|
adler-32: 1.2.0
|
||||||
cfb: 1.2.2
|
cfb: 1.2.2
|
||||||
codepage: 1.15.0
|
codepage: 1.14.0
|
||||||
|
commander: 2.17.1
|
||||||
crc-32: 1.2.2
|
crc-32: 1.2.2
|
||||||
|
exit-on-epipe: 1.0.1
|
||||||
|
fflate: 0.3.11
|
||||||
ssf: 0.11.2
|
ssf: 0.11.2
|
||||||
wmf: 1.0.2
|
wmf: 1.0.2
|
||||||
word: 0.3.0
|
word: 0.3.0
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function ColumnActionsDropdown({
|
|||||||
|
|
||||||
{canFilter && (
|
{canFilter && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
{canSort && <DropdownMenuSeparator />}
|
||||||
<DropdownMenuItem onClick={onFilterClick}>
|
<DropdownMenuItem onClick={onFilterClick}>
|
||||||
Filter By
|
Filter By
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -355,11 +355,20 @@ export function AdminTaskProvider({ children }) {
|
|||||||
[request]
|
[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(
|
const createTask = useCallback(
|
||||||
(taskListId, payload) =>
|
(taskListId, payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/${taskListId}/tasks`, payload);
|
const res = await api.post(`${BASE}/${taskListId}/tasks`, payload);
|
||||||
toast('Task created.');
|
toast('Task created.');
|
||||||
|
warnPreCompleted(res.data?.data?.warnings);
|
||||||
return res.data?.data ?? null;
|
return res.data?.data ?? null;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -370,6 +379,7 @@ export function AdminTaskProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch(`${BASE}/${taskListId}/tasks/${taskId}`, payload);
|
const res = await api.patch(`${BASE}/${taskListId}/tasks/${taskId}`, payload);
|
||||||
toast('Task updated.');
|
toast('Task updated.');
|
||||||
|
warnPreCompleted(res.data?.data?.warnings);
|
||||||
return res.data?.data ?? null;
|
return res.data?.data ?? null;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { buildSelectionActions } from "../../config/users/selection.config";
|
|||||||
import { buildRowActions } from "../../config/users/rowActions.config";
|
import { buildRowActions } from "../../config/users/rowActions.config";
|
||||||
|
|
||||||
import { USER_STAT_MAP } from "@/data/adminDashboard.data";
|
import { USER_STAT_MAP } from "@/data/adminDashboard.data";
|
||||||
|
import { ROLE_CONFIG } from "@/data/profile.data";
|
||||||
import { getTimestamp } from "@/utils/timestamp.util";
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
export default function UsersTable() {
|
export default function UsersTable() {
|
||||||
@@ -58,11 +59,17 @@ export default function UsersTable() {
|
|||||||
tableRefsRef.current = refs; // ← just store directly, no override needed
|
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 = {
|
const exportConfig = {
|
||||||
allData: users,
|
allData: users,
|
||||||
attributes,
|
attributes,
|
||||||
filename: `${getTimestamp()}_Users`,
|
filename: `${getTimestamp()}_Users`,
|
||||||
sheetName: "Users",
|
sheetName: "Users",
|
||||||
|
title: "Users",
|
||||||
|
generatedBy,
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ export default function CreateTask() {
|
|||||||
units={units}
|
units={units}
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
quizzes={quizzes}
|
quizzes={quizzes}
|
||||||
|
taskListId={taskListId}
|
||||||
/>
|
/>
|
||||||
{errors.requirements && (
|
{errors.requirements && (
|
||||||
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ export default function EditTask() {
|
|||||||
units={units}
|
units={units}
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
quizzes={quizzes}
|
quizzes={quizzes}
|
||||||
|
taskListId={taskListId}
|
||||||
/>
|
/>
|
||||||
{errors.requirements && (
|
{errors.requirements && (
|
||||||
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -183,15 +183,16 @@ function createRequirement(type = 'visit_link') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── RequirementBuilder ───────────────────────────────────────────────────────
|
// ─── 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(
|
const [items, setItems] = useState(
|
||||||
value.length > 0
|
value.length > 0
|
||||||
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
|
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
const [tierCategories, setTierCategories] = useState([]);
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
const [lockedDialog, setLockedDialog] = useState(null); // { title, tierLabel, contentType }
|
const [lockedDialog, setLockedDialog] = useState(null); // { title, tierLabel, contentType }
|
||||||
const [noContentDialog, setNoContentDialog] = useState(null); // { title, contentType }
|
const [noContentDialog, setNoContentDialog] = useState(null); // { title, contentType }
|
||||||
|
const [preCompletedDialog, setPreCompletedDialog] = useState(null); // { title, contentType, completedCount, totalAssignees }
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/admin/tiers/categories')
|
api.get('/admin/tiers/categories')
|
||||||
@@ -224,6 +225,8 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
updateItem(key, { allowed_file_types: next });
|
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) => {
|
const handleContentSelect = (key, content, contentType) => {
|
||||||
updateItem(key, {
|
updateItem(key, {
|
||||||
reference_id: content.uuid,
|
reference_id: content.uuid,
|
||||||
@@ -236,6 +239,30 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
const { rank, label } = resolveTierBadge(content.subscription ?? 'free', tierMap);
|
const { rank, label } = resolveTierBadge(content.subscription ?? 'free', tierMap);
|
||||||
if (rank > 0) setLockedDialog({ title: content.title, tierLabel: label, contentType });
|
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 (
|
return (
|
||||||
@@ -604,6 +631,29 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,11 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
|
|||||||
const isFetching = fetching[course.reference_id];
|
const isFetching = fetching[course.reference_id];
|
||||||
|
|
||||||
// ── Locked card ───────────────────────────────────────────────────
|
// ── 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={course.id}
|
key={course.id}
|
||||||
|
|||||||
@@ -85,7 +85,11 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
|
|||||||
const isFetching = fetching[lesson.reference_id];
|
const isFetching = fetching[lesson.reference_id];
|
||||||
|
|
||||||
// ── Locked card ───────────────────────────────────────
|
// ── 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={lesson.id}
|
key={lesson.id}
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
|||||||
const isFetching = fetching[unit.reference_id];
|
const isFetching = fetching[unit.reference_id];
|
||||||
|
|
||||||
// ── Locked card ───────────────────────────────────
|
// ── 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={unit.id}
|
key={unit.id}
|
||||||
|
|||||||
@@ -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 */
|
/** "Jun 27" — no year, for compact table cells */
|
||||||
export function fmtDateShort(value, { timezone = 'local', locale } = {}) {
|
export function fmtDateShort(value, { timezone = 'local', locale } = {}) {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
|
|||||||
+134
-17
@@ -1,8 +1,67 @@
|
|||||||
// ─── utils/exportToExcel.js ───────────────────────────────────────────────────
|
// ─── 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"]);
|
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 ────────────
|
// ─── Flatten a value into something a spreadsheet cell can display ────────────
|
||||||
// Array-of-object columns (e.g. `groups`: [{group_id, name, group_code}]) would
|
// Array-of-object columns (e.g. `groups`: [{group_id, name, group_code}]) would
|
||||||
// otherwise hit Array.prototype.toString → "[object Object]" per item.
|
// 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 ────────────────────────────
|
// ─── 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 "";
|
if (!key) return "";
|
||||||
|
|
||||||
// ─── Try direct key first e.g. "email", "acc_type" ────────────────────────
|
// ─── 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" ──────────
|
// ─── Dot-notation fallback e.g. "personal_info.name.full_name" ──────────
|
||||||
: key.split(".").reduce((acc, part) => acc?.[part] ?? "", row);
|
: 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) ?? "";
|
return flattenForExport(raw) ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +113,7 @@ function flattenRow(row, attributes) {
|
|||||||
|
|
||||||
for (const attr of attributes) {
|
for (const attr of attributes) {
|
||||||
const { field } = attr;
|
const { field } = attr;
|
||||||
result[field] = resolveValue(row, field);
|
result[field] = resolveValue(row, field, BOOLEAN_FIELD_LABELS[field]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -51,7 +125,7 @@ function flattenRow(row, attributes) {
|
|||||||
*
|
*
|
||||||
* @param {TableInstance|null} tableInstance
|
* @param {TableInstance|null} tableInstance
|
||||||
* @param {Array} attributes
|
* @param {Array} attributes
|
||||||
* @returns {Array} [{ key, label, width }]
|
* @returns {Array} [{ key, label, width, boolLabels }]
|
||||||
*/
|
*/
|
||||||
function resolveColumns(tableInstance, attributes) {
|
function resolveColumns(tableInstance, attributes) {
|
||||||
if (tableInstance) {
|
if (tableInstance) {
|
||||||
@@ -64,6 +138,7 @@ function resolveColumns(tableInstance, attributes) {
|
|||||||
col.columnDef.meta?.label ??
|
col.columnDef.meta?.label ??
|
||||||
(typeof col.columnDef.header === "string" ? col.columnDef.header : col.id),
|
(typeof col.columnDef.header === "string" ? col.columnDef.header : col.id),
|
||||||
width: col.columnDef.meta?.exportWidth ?? 20,
|
width: col.columnDef.meta?.exportWidth ?? 20,
|
||||||
|
boolLabels: BOOLEAN_FIELD_LABELS[col.id],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +148,7 @@ function resolveColumns(tableInstance, attributes) {
|
|||||||
key: attr.field,
|
key: attr.field,
|
||||||
label: attr.name ?? attr.field,
|
label: attr.name ?? attr.field,
|
||||||
width: attr.exportWidth ?? 20,
|
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 {TableInstance|null}[options.tableInstance] TanStack table — reads visible columns
|
||||||
* @param {string} [options.filename] Without extension. Default: "export"
|
* @param {string} [options.filename] Without extension. Default: "export"
|
||||||
* @param {string} [options.sheetName] Sheet tab name. Default: "Sheet1"
|
* @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
|
* @example
|
||||||
* // Toolbar export (all rows, visible columns)
|
* // 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
|
* @example
|
||||||
* // Selection export (checked rows only, visible columns)
|
* // Selection export (checked rows only, visible columns)
|
||||||
@@ -106,45 +186,82 @@ export function exportTableToExcel({
|
|||||||
tableInstance = null,
|
tableInstance = null,
|
||||||
filename = "export",
|
filename = "export",
|
||||||
sheetName = "Sheet1",
|
sheetName = "Sheet1",
|
||||||
|
title,
|
||||||
|
generatedBy,
|
||||||
|
timezone = "local",
|
||||||
|
footerNote = "Confidential – For authorized personnel only.",
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const columns = resolveColumns(tableInstance, attributes);
|
const columns = resolveColumns(tableInstance, attributes);
|
||||||
const data = Array.isArray(selectedRows) && selectedRows.length > 0
|
const data = Array.isArray(selectedRows) && selectedRows.length > 0
|
||||||
? selectedRows
|
? selectedRows
|
||||||
: allData;
|
: 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.
|
* Core export — writes a plain array of objects to an .xlsx file.
|
||||||
* Use exportTableToExcel above for table-aware dynamic exports.
|
* Use exportTableToExcel above for table-aware dynamic exports.
|
||||||
*
|
*
|
||||||
* @param {Object} options
|
* Every sheet gets the same layout: a merged, centered title (row 1) above
|
||||||
* @param {Array} options.data Rows to export
|
* a styled column-header row (row 2), the data, then a footer block with
|
||||||
* @param {Array} options.columns [{ key, label, width }]
|
* a generation stamp, record count, and confidentiality notice — so exports
|
||||||
* @param {string} [options.filename] Without extension. Default: "export"
|
* look consistent across every admin table without each call site having
|
||||||
* @param {string} [options.sheetName] Sheet tab name. Default: "Sheet1"
|
* 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({
|
export function exportToExcel({
|
||||||
data = [],
|
data = [],
|
||||||
columns = [],
|
columns = [],
|
||||||
filename = "export",
|
filename = "export",
|
||||||
sheetName = "Sheet1",
|
sheetName = "Sheet1",
|
||||||
|
title,
|
||||||
|
generatedBy,
|
||||||
|
timezone = "local",
|
||||||
|
footerNote = "Confidential – For authorized personnel only.",
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!data.length || !columns.length) {
|
if (!data.length || !columns.length) {
|
||||||
console.warn("exportToExcel: no data or columns to export.");
|
console.warn("exportToExcel: no data or columns to export.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = columns.map((c) => c.label ?? c.key);
|
const titleRow = [titleCell(title ?? sheetName), ...Array(columns.length - 1).fill("")];
|
||||||
const rows = data.map((row) =>
|
const headerRow = columns.map((c) => headerCell(c.label ?? c.key));
|
||||||
columns.map((c) => resolveValue(row, 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["!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();
|
const wb = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Search, ArrowUp, ArrowDown, ArrowUpDown, Eye } from "lucide-react";
|
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]
|
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 "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>;
|
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>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user