From 30b90051c92b90df19ace4557aac963422483bbe Mon Sep 17 00:00:00 2001 From: sokun1102 Date: Mon, 20 Apr 2026 18:40:29 +0700 Subject: [PATCH] feat: add programme module and blog integration - Add Programme Mongoose model with sub-schemas and migrateFromJson static method - Add programme CRUD controller with audit logging and icon sanitization - Add programme admin views (index, edit) with tabbed form and dynamic arrays - Add seed migration script for programmes - Add /api/programmes and /api/programmes/:id public API endpoints - Register programme routes in admin and public router - Update dashboard with Programmes card and API endpoint entries - Update admin sidebar layout to include Programmes nav link --- controllers/programmeController.js | 289 +++++++++ data/programmes.json | 68 +++ models/programme.js | 78 +++ routes/admin.js | 8 + routes/index.js | 5 + scripts/2026_04_20_150000_seed_programmes.js | 41 ++ scripts/migrate-programme.js | 53 ++ views/admin/dashboard.ejs | 65 +- views/admin/programme/edit.ejs | 610 +++++++++++++++++++ views/admin/programme/index.ejs | 90 +++ views/layouts/admin.ejs | 3 + views/layouts/main.ejs | 3 + 12 files changed, 1311 insertions(+), 2 deletions(-) create mode 100644 controllers/programmeController.js create mode 100644 data/programmes.json create mode 100644 models/programme.js create mode 100644 scripts/2026_04_20_150000_seed_programmes.js create mode 100644 scripts/migrate-programme.js create mode 100644 views/admin/programme/edit.ejs create mode 100644 views/admin/programme/index.ejs diff --git a/controllers/programmeController.js b/controllers/programmeController.js new file mode 100644 index 0000000..55f7afb --- /dev/null +++ b/controllers/programmeController.js @@ -0,0 +1,289 @@ +const { addBaseUrlToImages } = require("../utils/imageHelper"); +const { ICON_OPTIONS, normalizeIconClass } = require("../utils/iconOptions"); +const Programme = require("../models/programme"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); + +/** + * Normalize all icon fields in the payload against the ICON_OPTIONS whitelist. + */ +function sanitizeProgrammeIcons(payload) { + if (!payload) return payload; + if (Array.isArray(payload.coreCourses)) { + payload.coreCourses = payload.coreCourses.map((c) => ({ + ...c, + icon: normalizeIconClass(c.icon), + })); + } + if (Array.isArray(payload.outcomes)) { + payload.outcomes = payload.outcomes.map((o) => ({ + ...o, + icon: normalizeIconClass(o.icon), + })); + } + return payload; +} + +/** + * Safely parse a JSON field that may arrive as a string or already-parsed object. + */ +function parseJsonField(raw) { + if (raw == null || raw === "") return null; + if (typeof raw === "object") return raw; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +// ─── ADMIN ROUTES ───────────────────────────────────────────────────────────── + +/** + * GET /admin/programme + * List all programmes. + */ +exports.index = async (req, res) => { + try { + const data = await Programme.find().sort({ updatedAt: -1 }).lean(); + res.render("admin/programme/index", { + title: "Programmes Management", + data, + frontendUrl: process.env.FRONTEND_URL || "", + currentPath: req.path, + user: req.session.user, + }); + } catch (err) { + console.error("programme.index:", err); + req.flash("error_msg", "Error loading programmes"); + res.redirect("/admin/dashboard"); + } +}; + +/** + * GET /admin/programme/edit/:id + * Show edit form. Pass id = "new" to create. + */ +exports.edit = async (req, res) => { + try { + const { id } = req.params; + let programme; + + if (id === "new") { + programme = { + _id: null, + id: "", + level: "", + title: "", + description: "", + duration: "", + cost: "", + selected: false, + link: "", + shortName: "", + detailBadge: "", + detailTitle: "", + detailDescription: "", + heroImage: "", + overview: "", + credits: 120, + format: "100% Online", + nextStartDate: "", + monthlyCost: "", + perCourseCost: "", + coreCourses: [], + electives: [], + outcomes: [], + faqs: [], + }; + } else { + programme = await Programme.findById(id).lean(); + if (!programme) { + req.flash("error_msg", "Programme not found"); + return res.redirect("/admin/programme"); + } + } + + res.render("admin/programme/edit", { + title: id === "new" ? "Add New Programme" : `Edit: ${programme.title}`, + programme, + iconOptions: ICON_OPTIONS, + frontendUrl: process.env.FRONTEND_URL || "", + currentPath: req.path, + user: req.session.user, + }); + } catch (err) { + console.error("programme.edit:", err); + req.flash("error_msg", "Error loading programme"); + res.redirect("/admin/programme"); + } +}; + +/** + * POST /admin/programme/update/:id + * Save a new or existing programme. + */ +exports.update = async (req, res) => { + const { id } = req.params; + try { + const body = req.body; + + const coreCourses = parseJsonField(body.coreCourses) || []; + const outcomes = parseJsonField(body.outcomes) || []; + const faqs = parseJsonField(body.faqs) || []; + const electivesRaw = parseJsonField(body.electives) || []; + + // Electives arrive as [{value: "..."}, ...] from the form + const electives = electivesRaw.map((e) => + typeof e === "object" ? (e.value || "") : String(e) + ); + + let payload = { + id: (body.id || "").trim(), + level: (body.level || "").trim(), + title: (body.title || "").trim(), + description: (body.description || "").trim(), + duration: (body.duration || "").trim(), + cost: (body.cost || "").trim(), + selected: body.selected === "true" || body.selected === true, + link: (body.link || "").trim(), + shortName: (body.shortName || "").trim(), + detailBadge: (body.detailBadge || "").trim(), + detailTitle: (body.detailTitle || "").trim(), + detailDescription: (body.detailDescription || "").trim(), + overview: (body.overview || "").trim(), + credits: parseInt(body.credits, 10) || 0, + format: (body.format || "").trim(), + nextStartDate: (body.nextStartDate || "").trim(), + monthlyCost: (body.monthlyCost || "").trim(), + perCourseCost: (body.perCourseCost || "").trim(), + coreCourses, + electives, + outcomes, + faqs, + }; + + // Hero image: file upload takes priority over the text URL field + if (req.file) { + payload.heroImage = `/uploads/programmes/${req.file.filename}`; + } else if (body.heroImageUrl) { + payload.heroImage = body.heroImageUrl.trim(); + } + + // If link is empty, generate from id + if (!payload.link && payload.id) { + payload.link = `/programmes/${payload.id}`; + } + + payload = sanitizeProgrammeIcons(payload); + + if (id === "new") { + if (!payload.id) { + req.flash("error_msg", "Programme ID / Code is required"); + return res.redirect("/admin/programme/edit/new"); + } + const doc = await Programme.create(payload); + await writeAuditLog({ + model: "Programme", + documentId: doc._id, + action: AUDIT_ACTIONS.CREATE, + before: {}, + after: doc.toObject(), + changes: [], + req, + }); + req.flash("success_msg", `Programme "${payload.title}" created successfully`); + } else { + const existing = await Programme.findById(id); + if (!existing) { + req.flash("error_msg", "Programme not found"); + return res.redirect("/admin/programme"); + } + const beforeData = JSON.parse(JSON.stringify(existing.toObject())); + existing.set(payload); + await existing.save(); + const afterData = JSON.parse(JSON.stringify(existing.toObject())); + + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "Programme", + documentId: existing._id, + action: AUDIT_ACTIONS.UPDATE, + before: beforeData, + after: afterData, + changes, + req, + }); + } + req.flash("success_msg", `Programme "${existing.title}" updated successfully`); + } + + res.redirect("/admin/programme"); + } catch (err) { + console.error("programme.update:", err); + req.flash("error_msg", `Update error: ${err.message}`); + res.redirect(id === "new" ? "/admin/programme/edit/new" : `/admin/programme/edit/${id}`); + } +}; + +/** + * POST /admin/programme/delete/:id + * Delete a programme by MongoDB _id. + */ +exports.delete = async (req, res) => { + try { + const doc = await Programme.findByIdAndDelete(req.params.id); + if (doc) { + await writeAuditLog({ + model: "Programme", + documentId: req.params.id, + action: AUDIT_ACTIONS.DELETE, + before: doc.toObject(), + after: {}, + changes: [], + req, + }); + } + req.flash("success_msg", "Programme deleted successfully"); + res.redirect("/admin/programme"); + } catch (err) { + console.error("programme.delete:", err); + req.flash("error_msg", "Error deleting programme"); + res.redirect("/admin/programme"); + } +}; + +// ─── PUBLIC API ──────────────────────────────────────────────────────────────── + +/** + * GET /api/programmes + * Return all programmes as JSON. + */ +exports.api = async (req, res) => { + try { + const data = await Programme.find().sort({ updatedAt: -1 }).lean(); + const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; + res.json(addBaseUrlToImages(data, baseUrl)); + } catch (err) { + console.error("programme.api:", err); + res.status(500).json({ error: "Error loading programmes" }); + } +}; + +/** + * GET /api/programmes/:id + * Return a single programme matched by the slug `id` field. + */ +exports.apiDetail = async (req, res) => { + try { + const data = await Programme.findOne({ id: req.params.id }).lean(); + if (!data) return res.status(404).json({ error: "Programme not found" }); + const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; + res.json(addBaseUrlToImages(data, baseUrl)); + } catch (err) { + console.error("programme.apiDetail:", err); + res.status(500).json({ error: "Error loading programme" }); + } +}; diff --git a/data/programmes.json b/data/programmes.json new file mode 100644 index 0000000..b97f4ac --- /dev/null +++ b/data/programmes.json @@ -0,0 +1,68 @@ +[ + { + "id": "bs-cs", + "level": "Bachelor's", + "title": "B.S. in Computer Science", + "description": "Master software engineering, algorithms, and system design in this comprehensive asynchronous program.", + "duration": "4 Years (Flexible)", + "cost": "$299 / month", + "selected": false, + "link": "/programmes/bs-cs", + "shortName": "B.S.", + "detailBadge": "Bachelor of Science", + "detailTitle": "Computer Science", + "detailDescription": "Master software engineering, algorithms, and system design in this comprehensive asynchronous program designed for the modern tech landscape.", + "heroImage": "/uploads/programmes/bs-cs-hero.png", + "overview": "The B.S. in Computer Science program prepares you for a successful career in software development, data engineering, and systems architecture. Through our flexible, subscription-based model, you can learn at your own pace while mastering the core principles of computer science.", + "credits": 120, + "format": "100% Online", + "coreCourses": [ + { "id": "CS101", "title": "CS101: Introduction to Programming", "description": "Fundamentals of Python and computational thinking.", "icon": "fa-solid fa-code" }, + { "id": "CS201", "title": "CS201: Data Structures & Algorithms", "description": "Analysis and implementation of core data structures.", "icon": "fa-solid fa-database" }, + { "id": "CS301", "title": "CS301: Computer Networks", "description": "Protocols, routing, and network architecture.", "icon": "fa-solid fa-network-wired" } + ], + "electives": ["Artificial Intelligence", "Cybersecurity Fundamentals", "Cloud Computing", "Mobile App Development"], + "outcomes": [ + { "title": "Software Engineering", "description": "Design, develop, and test scalable software systems using modern methodologies.", "icon": "fa-solid fa-laptop-code" }, + { "title": "Problem Solving", "description": "Apply algorithmic thinking to solve complex computational problems efficiently.", "icon": "fa-solid fa-brain" } + ], + "faqs": [ + { "question": "Is this program fully asynchronous?", "answer": "Yes, all coursework is designed to be completed entirely online and on your own schedule. There are no mandatory live login times." }, + { "question": "Can I transfer credits from another institution?", "answer": "Absolutely. We accept up to 90 transfer credits for bachelor's degree programs from accredited institutions." } + ], + "nextStartDate": "September 1st", + "monthlyCost": "$299", + "perCourseCost": "$450" + }, + { + "id": "mba-db", + "level": "Master's", + "title": "MBA in Digital Business", + "description": "Lead the digital transformation with advanced business strategies and technology management skills.", + "duration": "18 Months", + "cost": "$450 / month", + "selected": true, + "link": "/programmes/mba-db", + "shortName": "MBA", + "detailBadge": "Master of Business Administration", + "detailTitle": "Digital Business", + "detailDescription": "Lead the digital transformation with advanced business strategies and technology management skills in our flexible online program.", + "heroImage": "/uploads/programmes/mba-db-hero.png", + "overview": "The MBA in Digital Business prepares executives and entrepreneurs for the demands of the modern digital economy.", + "credits": 45, + "format": "100% Online", + "coreCourses": [ + { "id": "MBA501", "title": "MBA501: Digital Transformation", "description": "Leading organizational change in the digital era.", "icon": "fa-solid fa-chart-line" } + ], + "electives": ["Digital Marketing", "Fintech Innovations"], + "outcomes": [ + { "title": "Leadership", "description": "Lead digital transformation initiatives.", "icon": "fa-solid fa-users" } + ], + "faqs": [ + { "question": "Do I need a GMAT?", "answer": "No, we have a holistic review process that does not require standardized tests." } + ], + "nextStartDate": "October 15th", + "monthlyCost": "$450", + "perCourseCost": "$1200" + } +] diff --git a/models/programme.js b/models/programme.js new file mode 100644 index 0000000..800a354 --- /dev/null +++ b/models/programme.js @@ -0,0 +1,78 @@ +const mongoose = require("mongoose"); + +const courseSchema = new mongoose.Schema( + { + id: { type: String, default: "" }, + title: { type: String, default: "" }, + description: { type: String, default: "" }, + icon: { type: String, default: "" }, + }, + { _id: false } +); + +const outcomeSchema = new mongoose.Schema( + { + title: { type: String, default: "" }, + description: { type: String, default: "" }, + icon: { type: String, default: "" }, + }, + { _id: false } +); + +const faqSchema = new mongoose.Schema( + { + question: { type: String, default: "" }, + answer: { type: String, default: "" }, + }, + { _id: false } +); + +const programmeSchema = new mongoose.Schema( + { + id: { type: String, required: true, unique: true, trim: true }, + level: { type: String, default: "" }, + title: { type: String, default: "" }, + description: { type: String, default: "" }, + duration: { type: String, default: "" }, + cost: { type: String, default: "" }, + selected: { type: Boolean, default: false }, + link: { type: String, default: "" }, + shortName: { type: String, default: "" }, + detailBadge: { type: String, default: "" }, + detailTitle: { type: String, default: "" }, + detailDescription: { type: String, default: "" }, + heroImage: { type: String, default: "" }, + overview: { type: String, default: "" }, + credits: { type: Number, default: 0 }, + format: { type: String, default: "100% Online" }, + nextStartDate: { type: String, default: "" }, + monthlyCost: { type: String, default: "" }, + perCourseCost: { type: String, default: "" }, + coreCourses: { type: [courseSchema], default: [] }, + electives: { type: [String], default: [] }, + outcomes: { type: [outcomeSchema], default: [] }, + faqs: { type: [faqSchema], default: [] }, + }, + { timestamps: true } +); + +/** + * Upsert from a JSON array. Used by migration scripts. + */ +programmeSchema.statics.migrateFromJson = async function (jsonArray) { + const results = []; + for (const item of jsonArray) { + const existing = await this.findOne({ id: item.id }); + if (existing) { + existing.set(item); + await existing.save(); + results.push({ action: "updated", id: item.id }); + } else { + await this.create(item); + results.push({ action: "created", id: item.id }); + } + } + return results; +}; + +module.exports = mongoose.model("Programme", programmeSchema); diff --git a/routes/admin.js b/routes/admin.js index a45f02f..95dcaf7 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -26,6 +26,7 @@ const activityController = require("../controllers/activityController"); const bookingSubmissionController = require("../controllers/bookingSubmissionController"); const serviceController = require("../controllers/serviceController"); const headerMenuController = require("../controllers/headerMenuController"); +const programmeController = require("../controllers/programmeController"); // Blog controllers const blogController = require("../controllers/blogController"); @@ -523,6 +524,13 @@ router.delete( ensureAuthenticated, visaController.deleteCountry, ); + +// Programme Management +router.get("/programme", ensureAuthenticated, programmeController.index); +router.get("/programme/edit/:id", ensureAuthenticated, programmeController.edit); +router.post("/programme/update/:id", ensureAuthenticated, upload.single('heroImage'), programmeController.update); +router.post("/programme/delete/:id", ensureAuthenticated, programmeController.delete); + // Blog routes // Blog Management Routes router.get("/blog", ensureAuthenticated, blogController.index); diff --git a/routes/index.js b/routes/index.js index 88fe137..529305e 100644 --- a/routes/index.js +++ b/routes/index.js @@ -15,6 +15,7 @@ const headerMenuController = require("../controllers/headerMenuController"); const safetyController = require("../controllers/safetyController"); // Booking flow removed +const programmeController = require("../controllers/programmeController"); const insuranceController = require("../controllers/insuranceController"); const termsController = require("../controllers/termsController"); // <-- IMPORT ĐÃ CÓ const activityController = require("../controllers/activityController"); @@ -183,6 +184,10 @@ router.get("/api/service-slugs", serviceController.getServiceSlugs); router.get("/api/visa", visaController.api); router.get("/api/visa/country", visaController.apiCountries); +// Programmes API +router.get("/api/programmes", programmeController.api); +router.get("/api/programmes/:id", programmeController.apiDetail); + // Testimonials API const testimonialController = require("../controllers/testimonialController"); router.get("/api/testimonials", testimonialController.api); diff --git a/scripts/2026_04_20_150000_seed_programmes.js b/scripts/2026_04_20_150000_seed_programmes.js new file mode 100644 index 0000000..9fbd616 --- /dev/null +++ b/scripts/2026_04_20_150000_seed_programmes.js @@ -0,0 +1,41 @@ +require("dotenv").config(); +const fs = require("fs").promises; +const path = require("path"); +const mongoose = require("mongoose"); +const connectDB = require("../config/database"); +const Programme = require("../models/programme"); + +/** + * Seed / upsert all programmes from data/programmes.json into MongoDB. + * Run: node scripts/2026_04_20_150000_seed_programmes.js + */ +async function migrate() { + try { + await connectDB(); + console.log("Connected to MongoDB"); + + const jsonPath = path.join(__dirname, "../data/programmes.json"); + const raw = await fs.readFile(jsonPath, "utf8"); + const data = JSON.parse(raw); + + if (!Array.isArray(data)) { + throw new Error("data/programmes.json must be a JSON array"); + } + + const results = await Programme.migrateFromJson(data); + console.log("Programme migration completed:"); + results.forEach((r) => console.log(` [${r.action}] id=${r.id}`)); + + await mongoose.disconnect(); + process.exit(0); + } catch (error) { + console.error("Programme migration error:", error.message); + process.exit(1); + } +} + +if (require.main === module) { + migrate(); +} + +module.exports = { migrate }; diff --git a/scripts/migrate-programme.js b/scripts/migrate-programme.js new file mode 100644 index 0000000..5f8ba11 --- /dev/null +++ b/scripts/migrate-programme.js @@ -0,0 +1,53 @@ +require('dotenv').config(); +const fs = require('fs').promises; +const path = require('path'); +const mongoose = require('mongoose'); +const connectDB = require('../config/database'); +const Programme = require('../models/programme'); + +async function validateProgrammeData(dataArray) { + if (!Array.isArray(dataArray)) { + throw new Error('Data must be an array of programme objects'); + } + + if (dataArray.length === 0) { + throw new Error('Programme array cannot be empty'); + } + + for (const item of dataArray) { + if (!item.id || !item.title || !item.level) { + throw new Error(`Programme is missing required fields (id, title, level). Error at id: ${item.id}`); + } + } +} + +async function migrateProgrammeData() { + try { + await connectDB(); + console.log('Đã kết nối đến MongoDB...'); + + await Programme.deleteMany({}); + console.log('Đã xóa dữ liệu Programme cũ'); + + const programmesData = JSON.parse( + await fs.readFile(path.join(__dirname, '../data/programmes.json'), 'utf8') + ); + + await validateProgrammeData(programmesData); + + const dataWithTimestamps = programmesData.map(item => ({ + ...item, + updatedAt: new Date() + })); + + await Programme.insertMany(dataWithTimestamps); + console.log(`✓ Migrate dữ liệu Programmes thành công (${dataWithTimestamps.length} items)!`); + process.exit(0); + + } catch (error) { + console.error('Lỗi:', error.message); + process.exit(1); + } +} + +migrateProgrammeData(); diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs index 4eb7ff1..1a8eee7 100644 --- a/views/admin/dashboard.ejs +++ b/views/admin/dashboard.ejs @@ -234,6 +234,25 @@ + + +
+
+
+
+ +
+
+
Programmes
+

Manage academic programmes

+
+
+ + Manage + +
+
@@ -242,7 +261,7 @@
API Endpoints
- 7 APIs + 9 APIs
@@ -421,13 +440,55 @@ GET - API to get blog posts + List published blog posts (supports ?page, ?category, ?search) View + + +
+
+ +
+ Programmes API +
+ + /api/programmes + + GET + + List all academic programmes + + + View + + + + + +
+
+ +
+ Programme Detail API +
+ + /api/programmes/:id + + GET + + Single programme by slug ID + + + View + + +
diff --git a/views/admin/programme/edit.ejs b/views/admin/programme/edit.ejs new file mode 100644 index 0000000..7666043 --- /dev/null +++ b/views/admin/programme/edit.ejs @@ -0,0 +1,610 @@ +
+
+
+

<%= title %>

+

Fields marked with * are required.

+
+
+ <% if (programme._id && frontendUrl) { %> + + Preview + + <% } %> + + Back to list + +
+
+ +
+ <%# Hidden JSON inputs for dynamic arrays %> + + + + + +
+ + +
+
+ + <%# ─── TAB 1: Basic Info ─────────────────────────────────────────── %> +
+
+
+ + +
Dùng làm URL slug: /programmes/bs-cs
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Tự sinh từ ID nếu để trống. Hiện tại: /programmes/<%= programme.id || '…' %>
+
+
+
+ > + +
+
+
+
+ + <%# ─── TAB 2: Detail Page ────────────────────────────────────────── %> +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ <% /* placeholder col */ %> +
+ + <%# Hero Image Upload %> +
+ +
+ + +
+ Hoặc upload file: + +
+
+
+ <% const hi = programme.heroImage; if (hi) { %> + <% let src = hi; if (!src.startsWith('http')) { src = src.startsWith('/') ? src : '/' + src; } %> + + <% } else { %> +
+
No image
+
+ <% } %> +
+
+
+
+ + <%# ─── TAB 3: Curriculum ─────────────────────────────────────────── %> +
+
+
+
+
Core Courses
+ Môn học bắt buộc trong chương trình +
+ +
+
+ <% (programme.coreCourses || []).forEach(function(course, idx) { %> +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ <% }); %> +
+
+ +
+ +
+
+
+
Electives
+ Môn học tự chọn +
+ +
+
+ <% (programme.electives || []).forEach(function(elective) { %> +
+ + +
+ <% }); %> +
+
+
+ + <%# ─── TAB 4: Outcomes ───────────────────────────────────────────── %> +
+
+
+
Learning Outcomes
+ Những gì sinh viên đạt được sau khoá học +
+ +
+
+ <% (programme.outcomes || []).forEach(function(outcome) { %> +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ <% }); %> +
+
+ + <%# ─── TAB 5: FAQs ───────────────────────────────────────────────── %> +
+
+
+
Frequently Asked Questions
+ Câu hỏi thường gặp trên trang chi tiết chương trình +
+ +
+
+ <% (programme.faqs || []).forEach(function(faq) { %> +
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+
+ <% }); %> +
+
+ + <%# ─── TAB 6: Pricing ────────────────────────────────────────────── %> +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
<%# end .tab-content %> +
<%# end .card-body %> +
<%# end .card %> + +
+ + Cancel + + +
+
+
+ + diff --git a/views/admin/programme/index.ejs b/views/admin/programme/index.ejs new file mode 100644 index 0000000..a2bf794 --- /dev/null +++ b/views/admin/programme/index.ejs @@ -0,0 +1,90 @@ +
+
+
+

Programmes Management

+

Manage all academic programmes displayed on the public website.

+
+
+ <% if (frontendUrl) { %> + + View Programmes Page + + <% } %> + + Add New Programme + +
+
+ +
+
+
+ + + + + + + + + + + + + + + <% if (data && data.length > 0) { %> + <% data.forEach(function(item) { %> + + + + + + + + + + + <% }); %> + <% } else { %> + + + + <% } %> + +
ID / CodeTitleLevelFormatCostCoursesFeaturedActions
+ <%= item.id %> + + <%= item.title %> + <% if (item.description) { %> +
<%= item.description %>
+ <% } %> +
+ <%= item.level || '—' %> + <%= item.format || '—' %><%= item.monthlyCost || item.cost || '—' %> + <%= (item.coreCourses || []).length %> + + <% if (item.selected) { %> + + <% } else { %> + + <% } %> + + + + +
+ +
+
+ + No programmes found. + Add your first programme → +
+
+
+
+
diff --git a/views/layouts/admin.ejs b/views/layouts/admin.ejs index d566c7e..4299c60 100644 --- a/views/layouts/admin.ejs +++ b/views/layouts/admin.ejs @@ -96,6 +96,9 @@ +
diff --git a/views/layouts/main.ejs b/views/layouts/main.ejs index d794dd6..3cfbee1 100644 --- a/views/layouts/main.ejs +++ b/views/layouts/main.ejs @@ -750,6 +750,9 @@ Pricing +