diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index cc6c51f..5aab2e9 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -104,6 +104,7 @@ import type { CreateParentLinkedPracticeResponse, UpdateParentLinkedPracticeRequest, UpdateParentLinkedPracticeResponse, + PublishParentLinkedPracticeRequest, UpdateTopLevelModuleLessonRequest, CreateTopLevelModuleLessonRequest, CreateTopLevelModuleLessonResponse, @@ -681,6 +682,12 @@ export const updateParentLinkedPractice = ( data: UpdateParentLinkedPracticeRequest, ) => http.put(`/practices/${practiceId}`, data) +/** PUT /practices/:id — set publish_status (e.g. publish a draft). */ +export const publishParentLinkedPractice = (practiceId: number) => + http.put(`/practices/${practiceId}`, { + publish_status: "PUBLISHED", + } satisfies PublishParentLinkedPracticeRequest) + /** DELETE /practices/:id */ export const deleteParentLinkedPractice = (practiceId: number) => http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>( diff --git a/src/api/rbac.api.ts b/src/api/rbac.api.ts index f14fea8..832f14b 100644 --- a/src/api/rbac.api.ts +++ b/src/api/rbac.api.ts @@ -8,6 +8,8 @@ import type { DeleteRoleResponse, SetRolePermissionsRequest, GetPermissionsResponse, + BulkRoleDeactivateResponse, + BulkRoleReactivateResponse, } from "../types/rbac.types" export const getRoles = (params?: GetRolesParams) => @@ -30,3 +32,11 @@ export const getAllPermissions = () => export const deleteRole = (roleId: number) => http.delete(`/rbac/roles/${roleId}`) + +/** Deactivate all users and team members tied to this role (admin). */ +export const bulkDeactivateRole = (roleId: number) => + http.post(`/admin/roles/${roleId}/bulk-deactivate`, {}) + +/** Reactivate users and team members tied to this role (admin). */ +export const bulkReactivateRole = (roleId: number) => + http.post(`/admin/roles/${roleId}/bulk-reactivate`, {}) diff --git a/src/lib/learnEnglishPracticePublish.ts b/src/lib/learnEnglishPracticePublish.ts index c9a03e5..1f07ae2 100644 --- a/src/lib/learnEnglishPracticePublish.ts +++ b/src/lib/learnEnglishPracticePublish.ts @@ -121,6 +121,7 @@ export async function executeLearnEnglishPracticeCreation(opts: { story_image: opts.storyImage.trim(), question_set_id: setId, quick_tips: opts.quickTips.trim(), + publish_status: opts.status, }) const practiceId = practiceRes.data?.data?.id diff --git a/src/lib/parentContextPractice.ts b/src/lib/parentContextPractice.ts new file mode 100644 index 0000000..cce0e25 --- /dev/null +++ b/src/lib/parentContextPractice.ts @@ -0,0 +1,48 @@ +import type { + GetPracticesByParentContextResponse, + ParentContextPractice, + PracticePublishStatus, +} from "../types/course.types" + +export function unwrapPracticesList( + res: { + data?: GetPracticesByParentContextResponse & { + Data?: GetPracticesByParentContextResponse["data"] + } + }, +): ParentContextPractice[] { + const body = res.data + if (!body) return [] + const data = body.data ?? body.Data + const raw = data?.practices + return Array.isArray(raw) ? raw : [] +} + +export function practicePublishStatus( + practice: ParentContextPractice, +): PracticePublishStatus | null { + const raw = practice.publish_status + if (raw === "DRAFT" || raw === "PUBLISHED") return raw + if (typeof raw === "string") { + const upper = raw.toUpperCase() + if (upper === "DRAFT" || upper === "PUBLISHED") { + return upper as PracticePublishStatus + } + } + return null +} + +export function isPracticePublished(practice: ParentContextPractice): boolean { + return practicePublishStatus(practice) === "PUBLISHED" +} + +export function isPracticeDraft(practice: ParentContextPractice): boolean { + const status = practicePublishStatus(practice) + return status === "DRAFT" || status === null +} + +export function draftPracticesForParent( + practices: ParentContextPractice[], +): ParentContextPractice[] { + return practices.filter(isPracticeDraft) +} diff --git a/src/pages/content-management/AddPracticeFlow.tsx b/src/pages/content-management/AddPracticeFlow.tsx index c3f1d5a..19fcc70 100644 --- a/src/pages/content-management/AddPracticeFlow.tsx +++ b/src/pages/content-management/AddPracticeFlow.tsx @@ -101,6 +101,7 @@ export function AddPracticeFlow() { description: "", storyImageUrl: "", shuffleQuestions: false, + publishStatus: "DRAFT" as const, tips: "", questions: [ { @@ -278,6 +279,7 @@ export function AddPracticeFlow() { description: "", storyImageUrl: "", shuffleQuestions: false, + publishStatus: "DRAFT" as const, tips: "", questions: [ { @@ -339,6 +341,7 @@ export function AddPracticeFlow() { return ( (null); const [editModuleName, setEditModuleName] = useState(""); const [editModuleDescription, setEditModuleDescription] = useState(""); + const [editModuleSortOrder, setEditModuleSortOrder] = useState(""); const [editModuleIcon, setEditModuleIcon] = useState(""); const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] = useState(false); @@ -159,6 +161,7 @@ export function CourseDetailPage() { setEditingModule(module); setEditModuleName(module.name ?? ""); setEditModuleDescription(module.description ?? ""); + setEditModuleSortOrder(String(module.sort_order ?? 0)); setEditModuleIcon(module.icon?.trim() ?? ""); setEditModuleIconUploadBusy(false); }; @@ -267,12 +270,23 @@ export function CourseDetailPage() { toast.error("Module name is required"); return; } + const sortOrderRaw = editModuleSortOrder.trim(); + if (!sortOrderRaw) { + toast.error("Sort order is required"); + return; + } + const sort_order = Number(sortOrderRaw); + if (!Number.isInteger(sort_order) || sort_order < 0) { + toast.error("Sort order must be a whole number of 0 or greater"); + return; + } setSavingModuleEdit(true); try { await updateTopLevelCourseModule(editingModule.id, { name, description: editModuleDescription.trim(), icon: editModuleIcon.trim(), + sort_order, }); toast.success("Module updated"); setEditModuleIconUploadBusy(false); @@ -412,18 +426,20 @@ export function CourseDetailPage() { if (!open) closeEditModule(); }} > - - + + Edit module - Update name, description, and icon (upload or URL). Saved with{" "} + Update name, description, sort order, and icon (upload or URL). + Saved with{" "} PUT /modules/:id . -
+
+
+
+ + setEditModuleSortOrder(e.target.value)} + className="rounded-xl" + placeholder="e.g. 5" + disabled={savingModuleEdit || editModuleIconUploadBusy} + /> +

+ Lower numbers appear first when modules are listed. +

+
- +
+ - +
diff --git a/src/pages/content-management/LearnEnglishPage.tsx b/src/pages/content-management/LearnEnglishPage.tsx index 22b069b..1c95bc2 100644 --- a/src/pages/content-management/LearnEnglishPage.tsx +++ b/src/pages/content-management/LearnEnglishPage.tsx @@ -24,9 +24,26 @@ import { updateLearningProgram, deleteLearningProgram, } from "../../api/courses.api"; -import { uploadImageFile } from "../../api/files.api"; +import { refreshFileUrl, uploadImageFile } from "../../api/files.api"; import type { LearningProgramListItem } from "../../types/course.types"; +/** Presigned MinIO/S3 URLs and our storage hosts — safe to send to POST /files/refresh-url. */ +function looksLikeRefreshableFileUrl(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) return false; + try { + const u = new URL(trimmed); + const q = u.search.toLowerCase(); + if (q.includes("x-amz-")) return true; + const h = u.hostname.toLowerCase(); + if (h.includes("yimaruacademy.com")) return true; + if (h.includes("minio")) return true; + return false; + } catch { + return false; + } +} + export function LearnEnglishPage() { const [programs, setPrograms] = useState([]); const [loading, setLoading] = useState(true); @@ -36,6 +53,7 @@ export function LearnEnglishPage() { useState(null); const [editName, setEditName] = useState(""); const [editDescription, setEditDescription] = useState(""); + const [editSortOrder, setEditSortOrder] = useState(""); const [editThumbnail, setEditThumbnail] = useState(""); const [savingEdit, setSavingEdit] = useState(false); const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false); @@ -44,6 +62,7 @@ export function LearnEnglishPage() { const [createOpen, setCreateOpen] = useState(false); const [createName, setCreateName] = useState(""); const [createDescription, setCreateDescription] = useState(""); + const [createSortOrder, setCreateSortOrder] = useState(""); const [createThumbnail, setCreateThumbnail] = useState(""); const [createSaving, setCreateSaving] = useState(false); const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false); @@ -57,6 +76,7 @@ export function LearnEnglishPage() { setEditingProgram(program); setEditName(program.name ?? ""); setEditDescription(program.description?.trim() ?? ""); + setEditSortOrder(String(program.sort_order ?? 0)); setEditThumbnail(program.thumbnail?.trim() ?? ""); }; @@ -64,6 +84,7 @@ export function LearnEnglishPage() { setEditingProgram(null); setEditName(""); setEditDescription(""); + setEditSortOrder(""); setEditThumbnail(""); setUploadingEditThumbnail(false); if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = ""; @@ -107,6 +128,7 @@ export function LearnEnglishPage() { const clearCreateFormFields = () => { setCreateName(""); setCreateDescription(""); + setCreateSortOrder(""); setCreateThumbnail(""); if (createThumbnailFileInputRef.current) { createThumbnailFileInputRef.current.value = ""; @@ -160,12 +182,23 @@ export function LearnEnglishPage() { toast.error("Program name is required"); return; } + const sortOrderRaw = createSortOrder.trim(); + if (!sortOrderRaw) { + toast.error("Sort order is required"); + return; + } + const sort_order = Number(sortOrderRaw); + if (!Number.isInteger(sort_order) || sort_order < 0) { + toast.error("Sort order must be a whole number of 0 or greater"); + return; + } setCreateSaving(true); try { await createLearningProgram({ name, description: createDescription.trim(), thumbnail: createThumbnail.trim(), + sort_order, }); toast.success("Program created"); clearCreateFormFields(); @@ -189,12 +222,23 @@ export function LearnEnglishPage() { toast.error("Program name is required"); return; } + const sortOrderRaw = editSortOrder.trim(); + if (!sortOrderRaw) { + toast.error("Sort order is required"); + return; + } + const sort_order = Number(sortOrderRaw); + if (!Number.isInteger(sort_order) || sort_order < 0) { + toast.error("Sort order must be a whole number of 0 or greater"); + return; + } setSavingEdit(true); try { await updateLearningProgram(editingProgram.id, { name, description: editDescription.trim(), thumbnail: editThumbnail.trim(), + sort_order, }); toast.success("Program updated"); closeEdit(); @@ -240,6 +284,35 @@ export function LearnEnglishPage() { (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0), ); setPrograms(sorted); + + void (async () => { + const results = await Promise.all( + sorted.map(async (p) => { + const ref = p.thumbnail?.trim(); + if (!ref || !looksLikeRefreshableFileUrl(ref)) return null; + try { + const res = await refreshFileUrl(ref); + const url = res.data?.data?.url?.trim(); + if (!url) return null; + return { id: p.id, url }; + } catch { + return null; + } + }), + ); + const map = new Map( + results + .filter((r): r is { id: number; url: string } => r != null) + .map((r) => [r.id, r.url] as const), + ); + if (map.size === 0) return; + setPrograms((prev) => + prev.map((prog) => { + const next = map.get(prog.id); + return next ? { ...prog, thumbnail: next } : prog; + }), + ); + })(); } catch (e) { console.error(e); setError("Failed to load programs"); @@ -348,6 +421,27 @@ export function LearnEnglishPage() { /> +
+ + setCreateSortOrder(e.target.value)} + placeholder="e.g. 5" + className="h-12 rounded-xl ring-0" + disabled={createSaving || createUploadingThumbnail} + /> +

+ Lower numbers appear first when programs are listed. +

+
+
+
- +
diff --git a/src/pages/content-management/components/PublishPracticeButton.tsx b/src/pages/content-management/components/PublishPracticeButton.tsx new file mode 100644 index 0000000..5d9c647 --- /dev/null +++ b/src/pages/content-management/components/PublishPracticeButton.tsx @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useState } from "react" +import { Loader2 } from "lucide-react" +import { toast } from "sonner" +import { + getPracticesByParentCourse, + getPracticesByParentModule, + publishParentLinkedPractice, +} from "../../../api/courses.api" +import type { PracticeParentKind } from "../../../types/course.types" +import { Button } from "../../../components/ui/button" +import { cn } from "../../../lib/utils" +import { + draftPracticesForParent, + isPracticePublished, + unwrapPracticesList, +} from "../../../lib/parentContextPractice" + +type Props = { + parentKind: Extract + parentId: number + className?: string + onPublished?: () => void +} + +export function PublishPracticeButton({ + parentKind, + parentId, + className, + onPublished, +}: Props) { + const [loading, setLoading] = useState(true) + const [publishing, setPublishing] = useState(false) + const [hasDraft, setHasDraft] = useState(false) + const [allPublished, setAllPublished] = useState(false) + const [hasPractice, setHasPractice] = useState(false) + + const loadPractices = useCallback(async () => { + if (!Number.isFinite(parentId) || parentId < 1) { + setHasPractice(false) + setHasDraft(false) + setAllPublished(false) + setLoading(false) + return + } + setLoading(true) + try { + const res = + parentKind === "COURSE" + ? await getPracticesByParentCourse(parentId, { limit: 50, offset: 0 }) + : await getPracticesByParentModule(parentId, { limit: 50, offset: 0 }) + const list = unwrapPracticesList(res) + const drafts = draftPracticesForParent(list) + setHasPractice(list.length > 0) + setHasDraft(drafts.length > 0) + setAllPublished( + list.length > 0 && list.every((p) => isPracticePublished(p)), + ) + } catch { + setHasPractice(false) + setHasDraft(false) + setAllPublished(false) + } finally { + setLoading(false) + } + }, [parentKind, parentId]) + + useEffect(() => { + void loadPractices() + }, [loadPractices]) + + const handlePublish = async () => { + if (!Number.isFinite(parentId) || parentId < 1) return + setPublishing(true) + try { + const res = + parentKind === "COURSE" + ? await getPracticesByParentCourse(parentId, { limit: 50, offset: 0 }) + : await getPracticesByParentModule(parentId, { limit: 50, offset: 0 }) + const drafts = draftPracticesForParent(unwrapPracticesList(res)) + if (drafts.length === 0) { + toast.info("No draft practice to publish") + await loadPractices() + return + } + for (const practice of drafts) { + await publishParentLinkedPractice(practice.id) + } + toast.success( + drafts.length === 1 + ? "Practice published" + : `${drafts.length} practices published`, + ) + await loadPractices() + onPublished?.() + } catch (e: unknown) { + const msg = + (e as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to publish practice" + toast.error(msg) + } finally { + setPublishing(false) + } + } + + const disabled = + loading || publishing || !hasPractice || !hasDraft || allPublished + + let label = "Publish Practice" + if (loading) label = "Loading…" + else if (publishing) label = "Publishing…" + else if (!hasPractice) label = "No practice" + else if (allPublished) label = "Published" + + return ( + + ) +} diff --git a/src/pages/content-management/components/practice-steps/ContextStep.tsx b/src/pages/content-management/components/practice-steps/ContextStep.tsx index 1a67b4a..ccfe4c8 100644 --- a/src/pages/content-management/components/practice-steps/ContextStep.tsx +++ b/src/pages/content-management/components/practice-steps/ContextStep.tsx @@ -6,6 +6,8 @@ import { Input } from "../../../../components/ui/input"; import { Textarea } from "../../../../components/ui/textarea"; import { toast } from "sonner"; import { uploadImageFile } from "../../../../api/files.api"; +import { PublishStatusField } from "./PublishStatusField"; +import type { PracticePublishStatus } from "../../../../types/course.types"; interface ContextStepProps { formData: any; @@ -168,6 +170,14 @@ export function ContextStep({ /> Shuffle questions in the set + + + setFormData({ ...formData, publishStatus }) + } + disabled={uploadingStory} + />
diff --git a/src/pages/content-management/components/practice-steps/PublishStatusField.tsx b/src/pages/content-management/components/practice-steps/PublishStatusField.tsx new file mode 100644 index 0000000..5d76ce0 --- /dev/null +++ b/src/pages/content-management/components/practice-steps/PublishStatusField.tsx @@ -0,0 +1,59 @@ +import type { PracticePublishStatus } from "../../../../types/course.types" +import { cn } from "../../../../lib/utils" + +type Props = { + value: PracticePublishStatus + onChange: (value: PracticePublishStatus) => void + disabled?: boolean + className?: string +} + +export function PublishStatusField({ value, onChange, disabled, className }: Props) { + return ( +
+

+ Publish status * +

+

+ Sent as publish_status on{" "} + POST /practices. +

+
+ {( + [ + { id: "DRAFT" as const, label: "Draft", hint: "Save without publishing to learners" }, + { id: "PUBLISHED" as const, label: "Published", hint: "Make the practice available" }, + ] as const + ).map((opt) => { + const selected = value === opt.id + return ( + + ) + })} +
+
+ ) +} diff --git a/src/pages/content-management/components/practice-steps/ReviewStep.tsx b/src/pages/content-management/components/practice-steps/ReviewStep.tsx index 4def1d3..1c76870 100644 --- a/src/pages/content-management/components/practice-steps/ReviewStep.tsx +++ b/src/pages/content-management/components/practice-steps/ReviewStep.tsx @@ -7,9 +7,12 @@ import { definitionUsesDynamicPayload, legacyQuestionTypeFromDefinition, } from "../../../../lib/learnEnglishDefinitionQuestion"; +import { PublishStatusField } from "./PublishStatusField"; +import type { PracticePublishStatus } from "../../../../types/course.types"; interface ReviewStepProps { formData: any; + setFormData: (data: any) => void; prevStep: () => void; parentSummary: string | null; typeDefinitions: QuestionTypeDefinition[]; @@ -21,6 +24,7 @@ interface ReviewStepProps { export function ReviewStep({ formData, + setFormData, prevStep, parentSummary, typeDefinitions, @@ -123,6 +127,13 @@ export function ReviewStep({
+ setFormData({ ...formData, publishStatus })} + disabled={submitting} + /> +
+ + setFormData({ ...formData, publishStatus }) + } + disabled={uploadingBanner} + />
diff --git a/src/pages/role-management/RolesListPage.tsx b/src/pages/role-management/RolesListPage.tsx index 78f0072..091d9d5 100644 --- a/src/pages/role-management/RolesListPage.tsx +++ b/src/pages/role-management/RolesListPage.tsx @@ -13,6 +13,8 @@ import { Pencil, Check, Trash2, + UserX, + UserCheck, } from "lucide-react" import { Button } from "../../components/ui/button" import { Card, CardContent } from "../../components/ui/card" @@ -29,6 +31,8 @@ import { setRolePermissions, updateRole, deleteRole, + bulkDeactivateRole, + bulkReactivateRole, } from "../../api/rbac.api" import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types" import { cn } from "../../lib/utils" @@ -58,6 +62,13 @@ export function RolesListPage() { const [roleToDelete, setRoleToDelete] = useState(null) const [deleteLoading, setDeleteLoading] = useState(false) + /** Bulk deactivate / reactivate (users + team members for this role). */ + const [bulkDialog, setBulkDialog] = useState<{ + type: "deactivate" | "reactivate" + role: Role + } | null>(null) + const [bulkActionLoading, setBulkActionLoading] = useState(false) + // Role info editing state const [editingRole, setEditingRole] = useState(false) const [editName, setEditName] = useState("") @@ -130,6 +141,39 @@ export function RolesListPage() { setRoleToDelete(null) } + const handleCancelBulkDialog = () => { + setBulkDialog(null) + } + + const handleConfirmBulkAction = async () => { + if (!bulkDialog) return + const { type, role } = bulkDialog + setBulkActionLoading(true) + try { + if (type === "deactivate") { + const res = await bulkDeactivateRole(role.id) + const d = res.data.data + toast.success(res.data.message ?? "Bulk deactivation completed", { + description: `${d.role}: ${d.users_deactivated} user(s), ${d.team_members_deactivated} team member(s) deactivated.`, + }) + } else { + const res = await bulkReactivateRole(role.id) + const d = res.data.data + toast.success(res.data.message ?? "Bulk reactivation completed", { + description: `${d.role}: ${d.users_reactivated} user(s), ${d.team_members_reactivated} team member(s) reactivated.`, + }) + } + setBulkDialog(null) + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + (type === "deactivate" ? "Bulk deactivation failed." : "Bulk reactivation failed.") + toast.error(message) + } finally { + setBulkActionLoading(false) + } + } + const handleConfirmDeleteRole = async () => { if (!roleToDelete) return setDeleteLoading(true) @@ -421,6 +465,31 @@ export function RolesListPage() {
+
+ + +
+
Open details to view permissions @@ -783,6 +852,72 @@ export function RolesListPage() { + {/* Bulk deactivate / reactivate confirmation */} + { + if (!open) handleCancelBulkDialog() + }} + > + + + + {bulkDialog?.type === "deactivate" ? ( + <> + + Deactivate all for this role? + + ) : ( + <> + + Reactivate all for this role? + + )} + + + {bulkDialog?.type === "deactivate" + ? "This deactivates every user and team member currently assigned to this role. They can be reactivated later with Reactivate all." + : "This reactivates users and team members tied to this role who were deactivated in bulk for this role."} + + + + {bulkDialog && ( +
+

{bulkDialog.role.name}

+

Role #{bulkDialog.role.id}

+
+ )} + +
+ + +
+
+
+ {/* Delete role dialog */}