feat: add progress tracking for course completion metrics

- Introduced a new module for progress tracking in the LMS service.
- Implemented `calculate_course_completion` function to compute total lessons, completed lessons, and progress percentage for a user in a specific course.
- Updated the main.rs file to include the new progress tracking module.
- Enhanced the Excel import functionality in the Question Bank to support various question types and improved error handling.
- Added a new dependency on the `xlsx` library for handling Excel files in the frontend.
- Modified the course settings page to include a branded certificate template with additional organization details.
- Updated the package.json and package-lock.json files to include the new `xlsx` dependency.
- Changed the default state for ingestRag in the Admin Shared Materials page to true.
This commit is contained in:
2026-04-22 10:08:27 -04:00
parent 1c67d0dac2
commit 77eceee2f3
13 changed files with 703 additions and 238 deletions
+122 -2
View File
@@ -18,7 +18,8 @@
"react": "^18",
"react-dom": "^18",
"react-markdown": "^10.1.0",
"tailwind-merge": "^2.3.0"
"tailwind-merge": "^2.3.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^20",
@@ -939,6 +940,7 @@
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1013,6 +1015,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -1496,6 +1499,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1512,6 +1516,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -1947,6 +1960,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2008,6 +2034,7 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz",
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.1.2",
"@chevrotain/gast": "11.1.2",
@@ -2078,6 +2105,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2136,6 +2172,18 @@
"layout-base": "^1.0.0"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2180,6 +2228,7 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
@@ -2589,6 +2638,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3104,6 +3154,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3266,6 +3317,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3684,6 +3736,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/framer-motion": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
@@ -4704,6 +4765,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -6196,6 +6258,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6348,6 +6411,7 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -6492,6 +6556,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -6503,6 +6568,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6589,7 +6655,8 @@
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"peer": true
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
@@ -7045,6 +7112,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -7521,6 +7600,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7707,6 +7787,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8079,6 +8160,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -8188,6 +8287,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2 -1
View File
@@ -23,7 +23,8 @@
"react": "^18",
"react-dom": "^18",
"react-markdown": "^10.1.0",
"tailwind-merge": "^2.3.0"
"tailwind-merge": "^2.3.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^20",
+1 -1
View File
@@ -6,7 +6,7 @@ import { Upload, Database, FileArchive, CheckCircle2, AlertTriangle, Scissors }
export default function AdminSharedMaterialsPage() {
const [zipFile, setZipFile] = useState<File | null>(null);
const [ingestRag, setIngestRag] = useState(false);
const [ingestRag, setIngestRag] = useState(true);
const [englishLevel, setEnglishLevel] = useState('');
const [plans, setPlans] = useState<MySqlPlan[]>([]);
const [courses, setCourses] = useState<MySqlCourse[]>([]);
+111 -15
View File
@@ -2,7 +2,7 @@
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { cmsApi, Course } from "@/lib/api";
import { cmsApi, Course, Organization, getImageUrl } from "@/lib/api";
import { Save, Settings as SettingsIcon, BookOpen, Calendar, Clock, Download, Upload, Copy, Wand2 } from "lucide-react";
const DEFAULT_CERTIFICATE_TEMPLATE = `
@@ -55,12 +55,54 @@ const MINIMAL_CERTIFICATE_TEMPLATE = `
</div>
`;
const BRANDED_CERTIFICATE_TEMPLATE = `
<div style="width: 900px; height: 620px; padding: 24px; box-sizing: border-box; font-family: 'Inter', 'Segoe UI', sans-serif; background: linear-gradient(145deg, {{primary_color}}, {{secondary_color}}); color: #0f172a;">
<div style="height: 100%; border-radius: 28px; background: rgba(255,255,255,0.94); padding: 48px 56px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: space-between; box-shadow: 0 24px 80px rgba(15, 23, 42, 0.18);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 24px;">
<div>
<p style="margin: 0 0 10px 0; font-size: 12px; letter-spacing: 0.24em; text-transform: uppercase; color: {{secondary_color}};">{{platform_name}}</p>
<h1 style="margin: 0; font-size: 50px; line-height: 1; color: #0f172a;">Premium Certificate</h1>
<p style="margin: 12px 0 0 0; color: #475569; font-size: 16px;">Issued by {{organization_name}}</p>
</div>
<div style="min-width: 96px; min-height: 96px; border-radius: 24px; background: #fff; border: 1px solid rgba(15, 23, 42, 0.08); display: flex; align-items: center; justify-content: center; padding: 12px; box-sizing: border-box;">
<img src="{{logo_url}}" alt="{{organization_name}}" style="max-width: 100%; max-height: 72px; object-fit: contain;" />
</div>
</div>
<div>
<p style="margin: 0 0 14px 0; text-transform: uppercase; letter-spacing: 0.22em; font-size: 12px; color: #64748b;">Awarded to</p>
<p style="margin: 0 0 18px 0; font-size: 42px; font-weight: 800; color: #0f172a;">{{student_name}}</p>
<p style="margin: 0 0 10px 0; font-size: 16px; color: #475569;">for successfully completing</p>
<p style="margin: 0; font-size: 30px; font-weight: 700; color: {{primary_color}};">{{course_title}}</p>
</div>
<div style="display: flex; justify-content: space-between; gap: 32px; align-items: flex-end;">
<div>
<p style="margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.18em; color: #64748b;">Completion</p>
<p style="margin: 0; font-size: 18px; font-weight: 700; color: #0f172a;">{{date}}</p>
</div>
<div>
<p style="margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.18em; color: #64748b;">Final Result</p>
<p style="margin: 0; font-size: 18px; font-weight: 700; color: #0f172a;">{{score}}</p>
</div>
<div style="text-align: right;">
<p style="margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.18em; color: #64748b;">Verification</p>
<p style="margin: 0; font-size: 14px; font-weight: 700; color: #0f172a;">{{verification_code}}</p>
</div>
</div>
</div>
</div>
`;
const TEMPLATE_VARIABLES = [
"{{student_name}}",
"{{course_title}}",
"{{date}}",
"{{score}}",
"{{verification_code}}",
{ token: "{{student_name}}", label: "Estudiante", description: "Nombre completo del estudiante." },
{ token: "{{course_title}}", label: "Curso", description: "Título del curso completado." },
{ token: "{{date}}", label: "Fecha", description: "Fecha de emisión del certificado." },
{ token: "{{score}}", label: "Resultado", description: "Resultado o puntaje final." },
{ token: "{{verification_code}}", label: "Verificación", description: "Código público de verificación." },
{ token: "{{organization_name}}", label: "Organización", description: "Nombre legal de la organización." },
{ token: "{{platform_name}}", label: "Plataforma", description: "Nombre comercial de la plataforma." },
{ token: "{{primary_color}}", label: "Color primario", description: "Color primario de branding." },
{ token: "{{secondary_color}}", label: "Color secundario", description: "Color secundario de branding." },
{ token: "{{logo_url}}", label: "Logo", description: "URL pública del logo organizacional." },
];
import CourseEditorLayout from "@/components/CourseEditorLayout";
@@ -71,6 +113,7 @@ export default function CourseSettingsPage() {
const { id } = useParams() as { id: string };
const router = useRouter();
const [course, setCourse] = useState<Course | null>(null);
const [organization, setOrganization] = useState<Organization | null>(null);
const [passingPercentage, setPassingPercentage] = useState(70);
const [certificateTemplate, setCertificateTemplate] = useState("");
const [loading, setLoading] = useState(true);
@@ -83,7 +126,7 @@ export default function CourseSettingsPage() {
const [price, setPrice] = useState(0);
const [currency, setCurrency] = useState("USD");
const [previewStudentName, setPreviewStudentName] = useState("Jane Doe");
const [previewScore, setPreviewScore] = useState("95");
const [previewScore, setPreviewScore] = useState("95%");
const [templateWarning, setTemplateWarning] = useState<string | null>(null);
const buildPreviewCertificate = () => {
@@ -91,11 +134,16 @@ export default function CourseSettingsPage() {
.replace(/{{student_name}}/g, previewStudentName || "Jane Doe")
.replace(/{{course_title}}/g, course?.title || "Demo Course")
.replace(/{{date}}/g, new Date().toLocaleDateString())
.replace(/{{score}}/g, previewScore || "95")
.replace(/{{verification_code}}/g, "OPENCCB-VERIFY-2026");
.replace(/{{score}}/g, previewScore || "95%")
.replace(/{{verification_code}}/g, "OPENCCB-VERIFY-2026")
.replace(/{{organization_name}}/g, organization?.name || "OpenCCB")
.replace(/{{platform_name}}/g, organization?.platform_name || organization?.name || "OpenCCB")
.replace(/{{primary_color}}/g, organization?.primary_color || "#2563eb")
.replace(/{{secondary_color}}/g, organization?.secondary_color || "#7c3aed")
.replace(/{{logo_url}}/g, organization?.logo_url ? getImageUrl(organization.logo_url) : "https://placehold.co/240x96?text=Logo");
};
const applyTemplatePreset = (preset: "default" | "modern" | "minimal") => {
const applyTemplatePreset = (preset: "default" | "modern" | "minimal" | "branded") => {
if (preset === "modern") {
setCertificateTemplate(MODERN_CERTIFICATE_TEMPLATE);
return;
@@ -104,6 +152,10 @@ export default function CourseSettingsPage() {
setCertificateTemplate(MINIMAL_CERTIFICATE_TEMPLATE);
return;
}
if (preset === "branded") {
setCertificateTemplate(BRANDED_CERTIFICATE_TEMPLATE);
return;
}
setCertificateTemplate(DEFAULT_CERTIFICATE_TEMPLATE);
};
@@ -125,14 +177,22 @@ export default function CourseSettingsPage() {
setTemplateWarning(`Faltan variables clave: ${missingCore.join(", ")}`);
return;
}
if (certificateTemplate.includes("{{logo_url}}") && !organization?.logo_url) {
setTemplateWarning("La plantilla usa {{logo_url}}, pero la organización no tiene logo configurado.");
return;
}
setTemplateWarning(null);
}, [certificateTemplate]);
}, [certificateTemplate, organization?.logo_url]);
useEffect(() => {
const fetchCourse = async () => {
try {
const data = await cmsApi.getCourse(id);
const [data, orgData] = await Promise.all([
cmsApi.getCourse(id),
cmsApi.getOrganization(),
]);
setCourse(data);
setOrganization(orgData);
setPassingPercentage(data.passing_percentage || 70);
setCertificateTemplate(data.certificate_template || DEFAULT_CERTIFICATE_TEMPLATE);
setPacingMode(data.pacing_mode || "self_paced");
@@ -451,19 +511,25 @@ export default function CourseSettingsPage() {
>
Minimal
</button>
<button
onClick={() => applyTemplatePreset("branded")}
className="px-3 py-1.5 rounded-lg bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-[10px] font-black uppercase tracking-widest hover:border-blue-500/40 transition-colors"
>
Premium
</button>
</div>
</div>
<div className="space-y-2">
<label className="block text-xs font-black text-slate-500 dark:text-gray-400 uppercase tracking-[0.2em]">Variables</label>
<div className="flex flex-wrap gap-2">
{TEMPLATE_VARIABLES.map((token) => (
<div key={token} className="flex items-center gap-1 bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-lg px-2 py-1">
{TEMPLATE_VARIABLES.map(({ token, label, description }) => (
<div key={token} className="flex items-center gap-1 bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-lg px-2 py-1" title={description}>
<button
onClick={() => insertVariable(token)}
className="text-[10px] font-black text-blue-600 dark:text-blue-400 hover:text-blue-700"
>
{token}
{label}
</button>
<button
onClick={() => copyVariable(token)}
@@ -484,6 +550,33 @@ export default function CourseSettingsPage() {
</div>
)}
{organization && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Organización</p>
<p className="mt-2 text-sm font-bold text-slate-800 dark:text-white">{organization.name}</p>
</div>
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Plataforma</p>
<p className="mt-2 text-sm font-bold text-slate-800 dark:text-white">{organization.platform_name || organization.name}</p>
</div>
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Color primario</p>
<div className="mt-2 flex items-center gap-2">
<span className="h-4 w-4 rounded-full border border-slate-200" style={{ backgroundColor: organization.primary_color || "#2563eb" }} />
<p className="text-sm font-bold text-slate-800 dark:text-white">{organization.primary_color || "#2563eb"}</p>
</div>
</div>
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Color secundario</p>
<div className="mt-2 flex items-center gap-2">
<span className="h-4 w-4 rounded-full border border-slate-200" style={{ backgroundColor: organization.secondary_color || "#7c3aed" }} />
<p className="text-sm font-bold text-slate-800 dark:text-white">{organization.secondary_color || "#7c3aed"}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-2">
<label className="block text-sm font-black text-slate-700 dark:text-gray-300 uppercase tracking-wider">HTML Template</label>
@@ -517,6 +610,9 @@ export default function CourseSettingsPage() {
placeholder="Score"
/>
</div>
<p className="text-[11px] text-slate-500 dark:text-gray-400 font-medium">
La previsualización usa el branding actual de la organización y resuelve el logo con la misma lógica pública usada por la plataforma.
</p>
<div className="w-full h-[400px] bg-white rounded-xl overflow-hidden relative group border border-slate-200 shadow-sm">
<iframe
srcDoc={buildPreviewCertificate()}
@@ -1,7 +1,8 @@
'use client';
import React, { useState } from 'react';
import { questionBankApi } from '@/lib/api';
import * as XLSX from 'xlsx';
import { CreateQuestionBankPayload, QuestionBankType, questionBankApi } from '@/lib/api';
import { X, Upload, FileSpreadsheet, Check, AlertCircle, Download } from 'lucide-react';
interface ExcelImportModalProps {
@@ -12,9 +13,134 @@ interface ExcelImportModalProps {
export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportModalProps) {
const [excelFile, setExcelFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<any>(null);
const [result, setResult] = useState<{ imported: number; skipped: number; error?: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const toQuestionType = (value: string): QuestionBankType | null => {
const normalized = value.trim().toLowerCase();
const mapping: Record<string, QuestionBankType> = {
'multiple-choice': 'multiple-choice',
'multiple choice': 'multiple-choice',
'mcq': 'multiple-choice',
'true-false': 'true-false',
'true false': 'true-false',
'boolean': 'true-false',
'short-answer': 'short-answer',
'short answer': 'short-answer',
'essay': 'essay',
'matching': 'matching',
'ordering': 'ordering',
'fill-in-the-blanks': 'fill-in-the-blanks',
'fill in the blanks': 'fill-in-the-blanks',
'audio-response': 'audio-response',
'audio response': 'audio-response',
'hotspot': 'hotspot',
'code-lab': 'code-lab',
'code lab': 'code-lab',
};
return mapping[normalized] || null;
};
const parseUnknownJson = (value: string): unknown => {
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
if (/^-?\d+$/.test(trimmed)) {
return Number.parseInt(trimmed, 10);
}
if (/^-?\d+\.\d+$/.test(trimmed)) {
return Number.parseFloat(trimmed);
}
return trimmed;
};
const parseOptions = (raw: string): unknown => {
const parsed = parseUnknownJson(raw);
if (Array.isArray(parsed)) return parsed;
if (typeof parsed === 'string') {
const pieces = parsed
.split(',')
.map((p) => p.trim())
.filter(Boolean);
return pieces.length > 0 ? pieces : undefined;
}
return undefined;
};
const parseExcelRows = async (file: File): Promise<CreateQuestionBankPayload[]> => {
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
if (!firstSheet) return [];
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(firstSheet, {
defval: '',
raw: false,
});
return rows
.map((row) => {
const getField = (name: string): string => {
const direct = row[name];
if (direct !== undefined) return String(direct).trim();
const lowerName = name.toLowerCase();
const foundKey = Object.keys(row).find((k) => k.trim().toLowerCase() === lowerName);
return foundKey ? String(row[foundKey]).trim() : '';
};
const questionText = getField('question_text');
const questionTypeRaw = getField('question_type');
const questionType = toQuestionType(questionTypeRaw);
if (!questionText || !questionType) return null;
const optionsRaw = getField('options');
const correctAnswerRaw = getField('correct_answer');
const explanation = getField('explanation') || undefined;
const difficultyRaw = getField('difficulty').toLowerCase();
const difficulty = ['easy', 'medium', 'hard'].includes(difficultyRaw) ? difficultyRaw : 'medium';
const tagsRaw = getField('tags');
const pointsRaw = getField('points');
let options = parseOptions(optionsRaw);
let correctAnswer = parseUnknownJson(correctAnswerRaw);
if (questionType === 'true-false') {
options = ['Verdadero', 'Falso'];
if (typeof correctAnswer === 'string') {
const lower = correctAnswer.toLowerCase();
correctAnswer = lower === 'verdadero' || lower === 'true' ? 0 : 1;
}
}
const tags = tagsRaw
? tagsRaw
.split(',')
.map((t) => t.trim())
.filter(Boolean)
: undefined;
const pointsNum = Number.parseInt(pointsRaw, 10);
return {
question_text: questionText,
question_type: questionType,
options,
correct_answer: correctAnswer,
explanation,
difficulty,
tags,
points: Number.isFinite(pointsNum) && pointsNum > 0 ? pointsNum : 1,
} as CreateQuestionBankPayload;
})
.filter((item): item is CreateQuestionBankPayload => item !== null);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
@@ -37,39 +163,61 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
setUploading(true);
setError(null);
const formData = new FormData();
formData.append('file', excelFile);
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/question-bank/import-excel`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: formData,
});
if (!response.ok) {
throw new Error('Error al importar');
const payloads = await parseExcelRows(excelFile);
if (payloads.length === 0) {
throw new Error('No se encontraron filas válidas en el archivo. Verifica columnas y tipos.');
}
const data = await response.json();
setResult(data);
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const payload of payloads) {
try {
await questionBankApi.create(payload);
imported += 1;
} catch (e) {
skipped += 1;
if (errors.length < 5) {
errors.push((e as Error).message || 'Error desconocido al crear pregunta');
}
}
}
setResult({
imported,
skipped,
error: errors.length > 0 ? errors.join(' | ') : undefined,
});
setTimeout(() => {
onSuccess?.();
}, 2000);
} catch (err: any) {
} catch (err: unknown) {
console.error('Excel import failed:', err);
setError(err.message || 'Error al importar');
setError((err as Error)?.message || 'Error al importar');
} finally {
setUploading(false);
}
};
const downloadTemplate = () => {
// Create a simple template explanation
alert('Descargando plantilla...\n\nColumnas requeridas:\n1. question_text - Texto de la pregunta\n2. question_type - multiple-choice, true-false, etc.\n3. options - ["A","B","C","D"]\n4. correct_answer - 0, 1, 2, o 3\n5. explanation - Explicación (opcional)\n6. difficulty - easy, medium, hard\n7. tags - tag1,tag2,tag3');
const csv = [
'question_text,question_type,options,correct_answer,explanation,difficulty,tags,points',
'What color is the sky?,multiple-choice,"[""Blue"",""Green"",""Red"",""Yellow""]",0,"The sky appears blue due to Rayleigh scattering",easy,"science,colors",1',
'The sun rises in the east.,true-false,"[""Verdadero"",""Falso""]",0,"The sun always rises in the east",easy,"geography",1',
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'question_bank_template.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (