UAT fixes stage 2
This commit is contained in:
parent
385f58fd22
commit
38550f9519
|
|
@ -104,6 +104,7 @@ import type {
|
||||||
CreateParentLinkedPracticeResponse,
|
CreateParentLinkedPracticeResponse,
|
||||||
UpdateParentLinkedPracticeRequest,
|
UpdateParentLinkedPracticeRequest,
|
||||||
UpdateParentLinkedPracticeResponse,
|
UpdateParentLinkedPracticeResponse,
|
||||||
|
PublishParentLinkedPracticeRequest,
|
||||||
UpdateTopLevelModuleLessonRequest,
|
UpdateTopLevelModuleLessonRequest,
|
||||||
CreateTopLevelModuleLessonRequest,
|
CreateTopLevelModuleLessonRequest,
|
||||||
CreateTopLevelModuleLessonResponse,
|
CreateTopLevelModuleLessonResponse,
|
||||||
|
|
@ -681,6 +682,12 @@ export const updateParentLinkedPractice = (
|
||||||
data: UpdateParentLinkedPracticeRequest,
|
data: UpdateParentLinkedPracticeRequest,
|
||||||
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
|
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
|
||||||
|
|
||||||
|
/** PUT /practices/:id — set publish_status (e.g. publish a draft). */
|
||||||
|
export const publishParentLinkedPractice = (practiceId: number) =>
|
||||||
|
http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, {
|
||||||
|
publish_status: "PUBLISHED",
|
||||||
|
} satisfies PublishParentLinkedPracticeRequest)
|
||||||
|
|
||||||
/** DELETE /practices/:id */
|
/** DELETE /practices/:id */
|
||||||
export const deleteParentLinkedPractice = (practiceId: number) =>
|
export const deleteParentLinkedPractice = (practiceId: number) =>
|
||||||
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
|
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import type {
|
||||||
DeleteRoleResponse,
|
DeleteRoleResponse,
|
||||||
SetRolePermissionsRequest,
|
SetRolePermissionsRequest,
|
||||||
GetPermissionsResponse,
|
GetPermissionsResponse,
|
||||||
|
BulkRoleDeactivateResponse,
|
||||||
|
BulkRoleReactivateResponse,
|
||||||
} from "../types/rbac.types"
|
} from "../types/rbac.types"
|
||||||
|
|
||||||
export const getRoles = (params?: GetRolesParams) =>
|
export const getRoles = (params?: GetRolesParams) =>
|
||||||
|
|
@ -30,3 +32,11 @@ export const getAllPermissions = () =>
|
||||||
|
|
||||||
export const deleteRole = (roleId: number) =>
|
export const deleteRole = (roleId: number) =>
|
||||||
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)
|
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)
|
||||||
|
|
||||||
|
/** Deactivate all users and team members tied to this role (admin). */
|
||||||
|
export const bulkDeactivateRole = (roleId: number) =>
|
||||||
|
http.post<BulkRoleDeactivateResponse>(`/admin/roles/${roleId}/bulk-deactivate`, {})
|
||||||
|
|
||||||
|
/** Reactivate users and team members tied to this role (admin). */
|
||||||
|
export const bulkReactivateRole = (roleId: number) =>
|
||||||
|
http.post<BulkRoleReactivateResponse>(`/admin/roles/${roleId}/bulk-reactivate`, {})
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ export async function executeLearnEnglishPracticeCreation(opts: {
|
||||||
story_image: opts.storyImage.trim(),
|
story_image: opts.storyImage.trim(),
|
||||||
question_set_id: setId,
|
question_set_id: setId,
|
||||||
quick_tips: opts.quickTips.trim(),
|
quick_tips: opts.quickTips.trim(),
|
||||||
|
publish_status: opts.status,
|
||||||
})
|
})
|
||||||
|
|
||||||
const practiceId = practiceRes.data?.data?.id
|
const practiceId = practiceRes.data?.data?.id
|
||||||
|
|
|
||||||
48
src/lib/parentContextPractice.ts
Normal file
48
src/lib/parentContextPractice.ts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -101,6 +101,7 @@ export function AddPracticeFlow() {
|
||||||
description: "",
|
description: "",
|
||||||
storyImageUrl: "",
|
storyImageUrl: "",
|
||||||
shuffleQuestions: false,
|
shuffleQuestions: false,
|
||||||
|
publishStatus: "DRAFT" as const,
|
||||||
tips: "",
|
tips: "",
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
|
|
@ -278,6 +279,7 @@ export function AddPracticeFlow() {
|
||||||
description: "",
|
description: "",
|
||||||
storyImageUrl: "",
|
storyImageUrl: "",
|
||||||
shuffleQuestions: false,
|
shuffleQuestions: false,
|
||||||
|
publishStatus: "DRAFT" as const,
|
||||||
tips: "",
|
tips: "",
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
|
|
@ -339,6 +341,7 @@ export function AddPracticeFlow() {
|
||||||
return (
|
return (
|
||||||
<ReviewStep
|
<ReviewStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
|
setFormData={setFormData}
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
parentSummary={parentSummary}
|
parentSummary={parentSummary}
|
||||||
typeDefinitions={typeDefinitions}
|
typeDefinitions={typeDefinitions}
|
||||||
|
|
@ -379,6 +382,7 @@ export function AddPracticeFlow() {
|
||||||
return (
|
return (
|
||||||
<ReviewStep
|
<ReviewStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
|
setFormData={setFormData}
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
parentSummary={parentSummary}
|
parentSummary={parentSummary}
|
||||||
typeDefinitions={typeDefinitions}
|
typeDefinitions={typeDefinitions}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import type {
|
||||||
} from "../../types/course.types";
|
} from "../../types/course.types";
|
||||||
import { AddModuleModal } from "./components/AddModuleModal";
|
import { AddModuleModal } from "./components/AddModuleModal";
|
||||||
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
|
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
|
||||||
|
import { PublishPracticeButton } from "./components/PublishPracticeButton";
|
||||||
|
|
||||||
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
|
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
|
||||||
|
|
||||||
|
|
@ -146,6 +147,7 @@ export function CourseDetailPage() {
|
||||||
useState<TopLevelCourseModuleItem | null>(null);
|
useState<TopLevelCourseModuleItem | null>(null);
|
||||||
const [editModuleName, setEditModuleName] = useState("");
|
const [editModuleName, setEditModuleName] = useState("");
|
||||||
const [editModuleDescription, setEditModuleDescription] = useState("");
|
const [editModuleDescription, setEditModuleDescription] = useState("");
|
||||||
|
const [editModuleSortOrder, setEditModuleSortOrder] = useState("");
|
||||||
const [editModuleIcon, setEditModuleIcon] = useState("");
|
const [editModuleIcon, setEditModuleIcon] = useState("");
|
||||||
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
|
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
@ -159,6 +161,7 @@ export function CourseDetailPage() {
|
||||||
setEditingModule(module);
|
setEditingModule(module);
|
||||||
setEditModuleName(module.name ?? "");
|
setEditModuleName(module.name ?? "");
|
||||||
setEditModuleDescription(module.description ?? "");
|
setEditModuleDescription(module.description ?? "");
|
||||||
|
setEditModuleSortOrder(String(module.sort_order ?? 0));
|
||||||
setEditModuleIcon(module.icon?.trim() ?? "");
|
setEditModuleIcon(module.icon?.trim() ?? "");
|
||||||
setEditModuleIconUploadBusy(false);
|
setEditModuleIconUploadBusy(false);
|
||||||
};
|
};
|
||||||
|
|
@ -267,12 +270,23 @@ export function CourseDetailPage() {
|
||||||
toast.error("Module name is required");
|
toast.error("Module name is required");
|
||||||
return;
|
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);
|
setSavingModuleEdit(true);
|
||||||
try {
|
try {
|
||||||
await updateTopLevelCourseModule(editingModule.id, {
|
await updateTopLevelCourseModule(editingModule.id, {
|
||||||
name,
|
name,
|
||||||
description: editModuleDescription.trim(),
|
description: editModuleDescription.trim(),
|
||||||
icon: editModuleIcon.trim(),
|
icon: editModuleIcon.trim(),
|
||||||
|
sort_order,
|
||||||
});
|
});
|
||||||
toast.success("Module updated");
|
toast.success("Module updated");
|
||||||
setEditModuleIconUploadBusy(false);
|
setEditModuleIconUploadBusy(false);
|
||||||
|
|
@ -412,18 +426,20 @@ export function CourseDetailPage() {
|
||||||
if (!open) closeEditModule();
|
if (!open) closeEditModule();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||||
<DialogHeader>
|
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
||||||
<DialogTitle>Edit module</DialogTitle>
|
<DialogTitle>Edit module</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update name, description, and icon (upload or URL). Saved with{" "}
|
Update name, description, sort order, and icon (upload or URL).
|
||||||
|
Saved with{" "}
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||||
PUT /modules/:id
|
PUT /modules/:id
|
||||||
</code>
|
</code>
|
||||||
.
|
.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-2">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||||
|
<div className="grid gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Name
|
Name
|
||||||
|
|
@ -446,9 +462,32 @@ export function CourseDetailPage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
className="min-h-[100px] resize-y rounded-xl"
|
className="min-h-[100px] resize-y rounded-xl"
|
||||||
placeholder="Optional short description."
|
placeholder="Optional short description."
|
||||||
disabled={savingModuleEdit}
|
disabled={savingModuleEdit || editModuleIconUploadBusy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="edit-module-sort-order"
|
||||||
|
className="text-sm font-medium text-grayScale-700"
|
||||||
|
>
|
||||||
|
Sort Order
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="edit-module-sort-order"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={editModuleSortOrder}
|
||||||
|
onChange={(e) => setEditModuleSortOrder(e.target.value)}
|
||||||
|
className="rounded-xl"
|
||||||
|
placeholder="e.g. 5"
|
||||||
|
disabled={savingModuleEdit || editModuleIconUploadBusy}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Lower numbers appear first when modules are listed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<ModuleIconUploadField
|
<ModuleIconUploadField
|
||||||
value={editModuleIcon}
|
value={editModuleIcon}
|
||||||
onChange={setEditModuleIcon}
|
onChange={setEditModuleIcon}
|
||||||
|
|
@ -456,7 +495,8 @@ export function CourseDetailPage() {
|
||||||
onUploadBusyChange={setEditModuleIconUploadBusy}
|
onUploadBusyChange={setEditModuleIconUploadBusy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
</div>
|
||||||
|
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -560,9 +600,11 @@ export function CourseDetailPage() {
|
||||||
>
|
>
|
||||||
View Detail
|
View Detail
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10">
|
<PublishPracticeButton
|
||||||
Publish Practice
|
parentKind="MODULE"
|
||||||
</Button>
|
parentId={module.id}
|
||||||
|
className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10 hover:bg-brand-600 disabled:opacity-60"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,26 @@ import {
|
||||||
updateLearningProgram,
|
updateLearningProgram,
|
||||||
deleteLearningProgram,
|
deleteLearningProgram,
|
||||||
} from "../../api/courses.api";
|
} from "../../api/courses.api";
|
||||||
import { uploadImageFile } from "../../api/files.api";
|
import { refreshFileUrl, uploadImageFile } from "../../api/files.api";
|
||||||
import type { LearningProgramListItem } from "../../types/course.types";
|
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() {
|
export function LearnEnglishPage() {
|
||||||
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
|
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -36,6 +53,7 @@ export function LearnEnglishPage() {
|
||||||
useState<LearningProgramListItem | null>(null);
|
useState<LearningProgramListItem | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editDescription, setEditDescription] = useState("");
|
const [editDescription, setEditDescription] = useState("");
|
||||||
|
const [editSortOrder, setEditSortOrder] = useState("");
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
||||||
|
|
@ -44,6 +62,7 @@ export function LearnEnglishPage() {
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
|
const [createSortOrder, setCreateSortOrder] = useState("");
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [createSaving, setCreateSaving] = useState(false);
|
const [createSaving, setCreateSaving] = useState(false);
|
||||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||||
|
|
@ -57,6 +76,7 @@ export function LearnEnglishPage() {
|
||||||
setEditingProgram(program);
|
setEditingProgram(program);
|
||||||
setEditName(program.name ?? "");
|
setEditName(program.name ?? "");
|
||||||
setEditDescription(program.description?.trim() ?? "");
|
setEditDescription(program.description?.trim() ?? "");
|
||||||
|
setEditSortOrder(String(program.sort_order ?? 0));
|
||||||
setEditThumbnail(program.thumbnail?.trim() ?? "");
|
setEditThumbnail(program.thumbnail?.trim() ?? "");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -64,6 +84,7 @@ export function LearnEnglishPage() {
|
||||||
setEditingProgram(null);
|
setEditingProgram(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
setEditDescription("");
|
setEditDescription("");
|
||||||
|
setEditSortOrder("");
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setUploadingEditThumbnail(false);
|
setUploadingEditThumbnail(false);
|
||||||
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
|
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
|
||||||
|
|
@ -107,6 +128,7 @@ export function LearnEnglishPage() {
|
||||||
const clearCreateFormFields = () => {
|
const clearCreateFormFields = () => {
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
setCreateDescription("");
|
setCreateDescription("");
|
||||||
|
setCreateSortOrder("");
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
if (createThumbnailFileInputRef.current) {
|
if (createThumbnailFileInputRef.current) {
|
||||||
createThumbnailFileInputRef.current.value = "";
|
createThumbnailFileInputRef.current.value = "";
|
||||||
|
|
@ -160,12 +182,23 @@ export function LearnEnglishPage() {
|
||||||
toast.error("Program name is required");
|
toast.error("Program name is required");
|
||||||
return;
|
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);
|
setCreateSaving(true);
|
||||||
try {
|
try {
|
||||||
await createLearningProgram({
|
await createLearningProgram({
|
||||||
name,
|
name,
|
||||||
description: createDescription.trim(),
|
description: createDescription.trim(),
|
||||||
thumbnail: createThumbnail.trim(),
|
thumbnail: createThumbnail.trim(),
|
||||||
|
sort_order,
|
||||||
});
|
});
|
||||||
toast.success("Program created");
|
toast.success("Program created");
|
||||||
clearCreateFormFields();
|
clearCreateFormFields();
|
||||||
|
|
@ -189,12 +222,23 @@ export function LearnEnglishPage() {
|
||||||
toast.error("Program name is required");
|
toast.error("Program name is required");
|
||||||
return;
|
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);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
await updateLearningProgram(editingProgram.id, {
|
await updateLearningProgram(editingProgram.id, {
|
||||||
name,
|
name,
|
||||||
description: editDescription.trim(),
|
description: editDescription.trim(),
|
||||||
thumbnail: editThumbnail.trim(),
|
thumbnail: editThumbnail.trim(),
|
||||||
|
sort_order,
|
||||||
});
|
});
|
||||||
toast.success("Program updated");
|
toast.success("Program updated");
|
||||||
closeEdit();
|
closeEdit();
|
||||||
|
|
@ -240,6 +284,35 @@ export function LearnEnglishPage() {
|
||||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||||
);
|
);
|
||||||
setPrograms(sorted);
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError("Failed to load programs");
|
setError("Failed to load programs");
|
||||||
|
|
@ -348,6 +421,27 @@ export function LearnEnglishPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="create-program-sort-order" className="text-[15px] text-grayScale-700">
|
||||||
|
Sort Order
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="create-program-sort-order"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={createSortOrder}
|
||||||
|
onChange={(e) => setCreateSortOrder(e.target.value)}
|
||||||
|
placeholder="e.g. 5"
|
||||||
|
className="h-12 rounded-xl ring-0"
|
||||||
|
disabled={createSaving || createUploadingThumbnail}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Lower numbers appear first when programs are listed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[15px] text-grayScale-700">
|
<label className="text-[15px] text-grayScale-700">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
|
|
@ -549,16 +643,17 @@ export function LearnEnglishPage() {
|
||||||
if (!open) closeEdit();
|
if (!open) closeEdit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||||
<DialogHeader>
|
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
||||||
<DialogTitle>Edit program</DialogTitle>
|
<DialogTitle>Edit program</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update name, description, and thumbnail. Upload an image from your
|
Update name, description, sort order, and thumbnail. Upload an image
|
||||||
computer (via file storage) or paste a URL. Changes are saved to the
|
from your computer (via file storage) or paste a URL. Changes are
|
||||||
server.
|
saved to the server.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-2">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||||
|
<div className="grid gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Name
|
Name
|
||||||
|
|
@ -584,6 +679,26 @@ export function LearnEnglishPage() {
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="edit-program-sort-order" className="text-sm font-medium text-grayScale-700">
|
||||||
|
Sort Order
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="edit-program-sort-order"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={editSortOrder}
|
||||||
|
onChange={(e) => setEditSortOrder(e.target.value)}
|
||||||
|
className="rounded-xl"
|
||||||
|
placeholder="e.g. 5"
|
||||||
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Lower numbers appear first when programs are listed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
|
|
@ -632,7 +747,8 @@ export function LearnEnglishPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
</div>
|
||||||
|
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import type {
|
||||||
LearningProgramListItem,
|
LearningProgramListItem,
|
||||||
ProgramCourseListItem,
|
ProgramCourseListItem,
|
||||||
} from "../../types/course.types";
|
} from "../../types/course.types";
|
||||||
|
import { PublishPracticeButton } from "./components/PublishPracticeButton";
|
||||||
|
|
||||||
export function ProgramCoursesPage() {
|
export function ProgramCoursesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -52,6 +53,7 @@ export function ProgramCoursesPage() {
|
||||||
);
|
);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editDescription, setEditDescription] = useState("");
|
const [editDescription, setEditDescription] = useState("");
|
||||||
|
const [editSortOrder, setEditSortOrder] = useState("");
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
||||||
|
|
@ -60,6 +62,7 @@ export function ProgramCoursesPage() {
|
||||||
const [createCourseOpen, setCreateCourseOpen] = useState(false);
|
const [createCourseOpen, setCreateCourseOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
|
const [createSortOrder, setCreateSortOrder] = useState("");
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [createSaving, setCreateSaving] = useState(false);
|
const [createSaving, setCreateSaving] = useState(false);
|
||||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||||
|
|
@ -137,12 +140,14 @@ export function ProgramCoursesPage() {
|
||||||
setEditThumbnail(
|
setEditThumbnail(
|
||||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
|
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
|
||||||
);
|
);
|
||||||
|
setEditSortOrder(String(course.sort_order ?? 0));
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditCourse = () => {
|
const closeEditCourse = () => {
|
||||||
setEditingCourse(null);
|
setEditingCourse(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
setEditDescription("");
|
setEditDescription("");
|
||||||
|
setEditSortOrder("");
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setUploadingEditThumbnail(false);
|
setUploadingEditThumbnail(false);
|
||||||
if (editThumbnailFileInputRef.current) {
|
if (editThumbnailFileInputRef.current) {
|
||||||
|
|
@ -192,12 +197,23 @@ export function ProgramCoursesPage() {
|
||||||
toast.error("Course name is required");
|
toast.error("Course name is required");
|
||||||
return;
|
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);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
await updateTopLevelCourse(editingCourse.id, {
|
await updateTopLevelCourse(editingCourse.id, {
|
||||||
name,
|
name,
|
||||||
description: editDescription.trim(),
|
description: editDescription.trim(),
|
||||||
thumbnail: editThumbnail.trim(),
|
thumbnail: editThumbnail.trim(),
|
||||||
|
sort_order,
|
||||||
});
|
});
|
||||||
toast.success("Course updated");
|
toast.success("Course updated");
|
||||||
closeEditCourse();
|
closeEditCourse();
|
||||||
|
|
@ -216,6 +232,7 @@ export function ProgramCoursesPage() {
|
||||||
const clearCreateCourseForm = () => {
|
const clearCreateCourseForm = () => {
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
setCreateDescription("");
|
setCreateDescription("");
|
||||||
|
setCreateSortOrder("");
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
setCreateUploadingThumbnail(false);
|
setCreateUploadingThumbnail(false);
|
||||||
if (createThumbnailFileInputRef.current) {
|
if (createThumbnailFileInputRef.current) {
|
||||||
|
|
@ -271,12 +288,23 @@ export function ProgramCoursesPage() {
|
||||||
toast.error("Course name is required");
|
toast.error("Course name is required");
|
||||||
return;
|
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);
|
setCreateSaving(true);
|
||||||
try {
|
try {
|
||||||
await createProgramCourse(programId, {
|
await createProgramCourse(programId, {
|
||||||
name,
|
name,
|
||||||
description: createDescription.trim(),
|
description: createDescription.trim(),
|
||||||
thumbnail: createThumbnail.trim(),
|
thumbnail: createThumbnail.trim(),
|
||||||
|
sort_order,
|
||||||
});
|
});
|
||||||
toast.success("Course created");
|
toast.success("Course created");
|
||||||
clearCreateCourseForm();
|
clearCreateCourseForm();
|
||||||
|
|
@ -435,6 +463,30 @@ export function ProgramCoursesPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="create-course-sort-order"
|
||||||
|
className="text-[15px] font-medium text-grayScale-700"
|
||||||
|
>
|
||||||
|
Sort Order
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="create-course-sort-order"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={createSortOrder}
|
||||||
|
onChange={(e) => setCreateSortOrder(e.target.value)}
|
||||||
|
placeholder="e.g. 5"
|
||||||
|
className="h-12 rounded-xl"
|
||||||
|
disabled={createSaving || createUploadingThumbnail}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Lower numbers appear first when courses are listed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[15px] font-medium text-grayScale-700">
|
<label className="text-[15px] font-medium text-grayScale-700">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
|
|
@ -664,9 +716,11 @@ export function ProgramCoursesPage() {
|
||||||
>
|
>
|
||||||
View Detail
|
View Detail
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold ">
|
<PublishPracticeButton
|
||||||
Publish Practice
|
parentKind="COURSE"
|
||||||
</Button>
|
parentId={course.id}
|
||||||
|
className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold hover:bg-brand-600 disabled:opacity-60"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -682,18 +736,19 @@ export function ProgramCoursesPage() {
|
||||||
if (!open) closeEditCourse();
|
if (!open) closeEditCourse();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||||
<DialogHeader>
|
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
||||||
<DialogTitle>Edit course</DialogTitle>
|
<DialogTitle>Edit course</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update name, description, and thumbnail. Saved with{" "}
|
Update name, description, sort order, and thumbnail. Saved with{" "}
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||||
PUT /courses/:id
|
PUT /courses/:id
|
||||||
</code>
|
</code>
|
||||||
.
|
.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-2">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||||
|
<div className="grid gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Name
|
Name
|
||||||
|
|
@ -719,6 +774,29 @@ export function ProgramCoursesPage() {
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="edit-course-sort-order"
|
||||||
|
className="text-sm font-medium text-grayScale-700"
|
||||||
|
>
|
||||||
|
Sort Order
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="edit-course-sort-order"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={editSortOrder}
|
||||||
|
onChange={(e) => setEditSortOrder(e.target.value)}
|
||||||
|
className="rounded-xl"
|
||||||
|
placeholder="e.g. 5"
|
||||||
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Lower numbers appear first when courses are listed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
|
|
@ -760,7 +838,8 @@ export function ProgramCoursesPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
</div>
|
||||||
|
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export function AddModuleModal({
|
||||||
}: AddModuleModalProps) {
|
}: AddModuleModalProps) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
const [sortOrder, setSortOrder] = useState("");
|
||||||
const [icon, setIcon] = useState("");
|
const [icon, setIcon] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [iconUploadBusy, setIconUploadBusy] = useState(false);
|
const [iconUploadBusy, setIconUploadBusy] = useState(false);
|
||||||
|
|
@ -37,6 +38,7 @@ export function AddModuleModal({
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
setSortOrder("");
|
||||||
setIcon("");
|
setIcon("");
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setIconUploadBusy(false);
|
setIconUploadBusy(false);
|
||||||
|
|
@ -46,6 +48,7 @@ export function AddModuleModal({
|
||||||
const resetAndClose = () => {
|
const resetAndClose = () => {
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
setSortOrder("");
|
||||||
setIcon("");
|
setIcon("");
|
||||||
setIconUploadBusy(false);
|
setIconUploadBusy(false);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -69,12 +72,23 @@ export function AddModuleModal({
|
||||||
toast.error("Invalid course");
|
toast.error("Invalid course");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sortOrderRaw = sortOrder.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;
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createTopLevelCourseModule(courseId, {
|
await createTopLevelCourseModule(courseId, {
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
icon: icon.trim(),
|
icon: icon.trim(),
|
||||||
|
sort_order,
|
||||||
});
|
});
|
||||||
toast.success("Module created");
|
toast.success("Module created");
|
||||||
if (onCreated) {
|
if (onCreated) {
|
||||||
|
|
@ -157,6 +171,30 @@ export function AddModuleModal({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="create-module-sort-order"
|
||||||
|
className="text-[15px] font-medium text-grayScale-700"
|
||||||
|
>
|
||||||
|
Sort Order
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="create-module-sort-order"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(e.target.value)}
|
||||||
|
placeholder="e.g. 5"
|
||||||
|
className="h-12 rounded-xl"
|
||||||
|
disabled={submitting || iconUploadBusy}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Lower numbers appear first when modules are listed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ModuleIconUploadField
|
<ModuleIconUploadField
|
||||||
value={icon}
|
value={icon}
|
||||||
onChange={setIcon}
|
onChange={setIcon}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@ import {
|
||||||
createQuestion,
|
createQuestion,
|
||||||
createQuestionSet,
|
createQuestionSet,
|
||||||
} from "../../../api/courses.api"
|
} from "../../../api/courses.api"
|
||||||
import type { CreateQuestionRequest, PracticeParentKind } from "../../../types/course.types"
|
import type {
|
||||||
|
CreateQuestionRequest,
|
||||||
|
PracticeParentKind,
|
||||||
|
PracticePublishStatus,
|
||||||
|
} from "../../../types/course.types"
|
||||||
|
import { PublishStatusField } from "./practice-steps/PublishStatusField"
|
||||||
import { cn } from "../../../lib/utils"
|
import { cn } from "../../../lib/utils"
|
||||||
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
|
||||||
|
|
||||||
|
|
@ -60,6 +65,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
const [storyDescription, setStoryDescription] = useState("")
|
const [storyDescription, setStoryDescription] = useState("")
|
||||||
const [storyImage, setStoryImage] = useState("")
|
const [storyImage, setStoryImage] = useState("")
|
||||||
const [quickTips, setQuickTips] = useState("")
|
const [quickTips, setQuickTips] = useState("")
|
||||||
|
const [publishStatus, setPublishStatus] = useState<PracticePublishStatus>("DRAFT")
|
||||||
|
|
||||||
const canUseWizard = parent != null
|
const canUseWizard = parent != null
|
||||||
|
|
||||||
|
|
@ -79,6 +85,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
setStoryDescription("")
|
setStoryDescription("")
|
||||||
setStoryImage("")
|
setStoryImage("")
|
||||||
setQuickTips("")
|
setQuickTips("")
|
||||||
|
setPublishStatus("DRAFT")
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleStep1 = async () => {
|
const handleStep1 = async () => {
|
||||||
|
|
@ -173,7 +180,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStep4 = async () => {
|
const handleStep4 = async (status: PracticePublishStatus) => {
|
||||||
if (!parent || questionSetId == null) return
|
if (!parent || questionSetId == null) return
|
||||||
if (!practiceTitle.trim() || !storyDescription.trim() || !storyImage.trim()) {
|
if (!practiceTitle.trim() || !storyDescription.trim() || !storyImage.trim()) {
|
||||||
toast.error("Title, story description, and story image are required")
|
toast.error("Title, story description, and story image are required")
|
||||||
|
|
@ -189,8 +196,11 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
story_image: storyImage.trim(),
|
story_image: storyImage.trim(),
|
||||||
question_set_id: questionSetId,
|
question_set_id: questionSetId,
|
||||||
quick_tips: quickTips.trim(),
|
quick_tips: quickTips.trim(),
|
||||||
|
publish_status: status,
|
||||||
})
|
})
|
||||||
toast.success("Practice created successfully")
|
toast.success(
|
||||||
|
status === "PUBLISHED" ? "Practice published" : "Practice saved as draft",
|
||||||
|
)
|
||||||
resetAll()
|
resetAll()
|
||||||
onCreated?.()
|
onCreated?.()
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|
@ -463,14 +473,42 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<PublishStatusField
|
||||||
|
value={publishStatus}
|
||||||
|
onChange={setPublishStatus}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setStep(3)} disabled={saving}>
|
<Button type="button" variant="outline" onClick={() => setStep(3)} disabled={saving}>
|
||||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={handleStep4} disabled={saving}>
|
<Button
|
||||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : <ChevronRight className="mr-1.5 h-4 w-4" />}
|
type="button"
|
||||||
Create practice
|
variant="outline"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setPublishStatus("DRAFT")
|
||||||
|
void handleStep4("DRAFT")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving && publishStatus === "DRAFT" ? (
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
) : null}
|
||||||
|
Save as draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setPublishStatus("PUBLISHED")
|
||||||
|
void handleStep4("PUBLISHED")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving && publishStatus === "PUBLISHED" ? (
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
) : null}
|
||||||
|
Publish practice
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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<PracticeParentKind, "COURSE" | "MODULE">
|
||||||
|
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 (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className={cn(className)}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => void handlePublish()}
|
||||||
|
title={
|
||||||
|
allPublished
|
||||||
|
? "Practice is already published"
|
||||||
|
: !hasPractice
|
||||||
|
? "No practice linked to this item yet"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(loading || publishing) && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ import { Input } from "../../../../components/ui/input";
|
||||||
import { Textarea } from "../../../../components/ui/textarea";
|
import { Textarea } from "../../../../components/ui/textarea";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { uploadImageFile } from "../../../../api/files.api";
|
import { uploadImageFile } from "../../../../api/files.api";
|
||||||
|
import { PublishStatusField } from "./PublishStatusField";
|
||||||
|
import type { PracticePublishStatus } from "../../../../types/course.types";
|
||||||
|
|
||||||
interface ContextStepProps {
|
interface ContextStepProps {
|
||||||
formData: any;
|
formData: any;
|
||||||
|
|
@ -168,6 +170,14 @@ export function ContextStep({
|
||||||
/>
|
/>
|
||||||
<span>Shuffle questions in the set</span>
|
<span>Shuffle questions in the set</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<PublishStatusField
|
||||||
|
value={(formData.publishStatus ?? "DRAFT") as PracticePublishStatus}
|
||||||
|
onChange={(publishStatus) =>
|
||||||
|
setFormData({ ...formData, publishStatus })
|
||||||
|
}
|
||||||
|
disabled={uploadingStory}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
|
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div className={cn("space-y-2", className)}>
|
||||||
|
<p className="text-sm font-medium text-grayScale-700">
|
||||||
|
Publish status <span className="text-red-500">*</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Sent as <code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">publish_status</code> on{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">POST /practices</code>.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3" role="radiogroup" aria-label="Publish status">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ 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 (
|
||||||
|
<label
|
||||||
|
key={opt.id}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-[140px] flex-1 cursor-pointer flex-col rounded-xl border px-4 py-3 transition-colors",
|
||||||
|
selected
|
||||||
|
? "border-brand-500 bg-brand-50/60 ring-1 ring-brand-500/30"
|
||||||
|
: "border-grayScale-200 bg-white hover:border-grayScale-300",
|
||||||
|
disabled && "cursor-not-allowed opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="publish_status"
|
||||||
|
value={opt.id}
|
||||||
|
checked={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={() => onChange(opt.id)}
|
||||||
|
className="h-4 w-4 border-grayScale-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-grayScale-800">{opt.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 pl-6 text-xs text-grayScale-500">{opt.hint}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,9 +7,12 @@ import {
|
||||||
definitionUsesDynamicPayload,
|
definitionUsesDynamicPayload,
|
||||||
legacyQuestionTypeFromDefinition,
|
legacyQuestionTypeFromDefinition,
|
||||||
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
||||||
|
import { PublishStatusField } from "./PublishStatusField";
|
||||||
|
import type { PracticePublishStatus } from "../../../../types/course.types";
|
||||||
|
|
||||||
interface ReviewStepProps {
|
interface ReviewStepProps {
|
||||||
formData: any;
|
formData: any;
|
||||||
|
setFormData: (data: any) => void;
|
||||||
prevStep: () => void;
|
prevStep: () => void;
|
||||||
parentSummary: string | null;
|
parentSummary: string | null;
|
||||||
typeDefinitions: QuestionTypeDefinition[];
|
typeDefinitions: QuestionTypeDefinition[];
|
||||||
|
|
@ -21,6 +24,7 @@ interface ReviewStepProps {
|
||||||
|
|
||||||
export function ReviewStep({
|
export function ReviewStep({
|
||||||
formData,
|
formData,
|
||||||
|
setFormData,
|
||||||
prevStep,
|
prevStep,
|
||||||
parentSummary,
|
parentSummary,
|
||||||
typeDefinitions,
|
typeDefinitions,
|
||||||
|
|
@ -123,6 +127,13 @@ export function ReviewStep({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PublishStatusField
|
||||||
|
className="px-2"
|
||||||
|
value={(formData.publishStatus ?? "DRAFT") as PracticePublishStatus}
|
||||||
|
onChange={(publishStatus) => setFormData({ ...formData, publishStatus })}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-12">
|
<div className="flex items-center justify-between pt-12">
|
||||||
<Button
|
<Button
|
||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
|
|
@ -135,7 +146,10 @@ export function ReviewStep({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={submitting || !canPublish}
|
disabled={submitting || !canPublish}
|
||||||
onClick={onSaveDraft}
|
onClick={() => {
|
||||||
|
setFormData({ ...formData, publishStatus: "DRAFT" });
|
||||||
|
onSaveDraft();
|
||||||
|
}}
|
||||||
className="h-10 rounded-[6px] border-grayScale-100 bg-white px-8 text-sm font-bold text-grayScale-600 shadow-sm hover:bg-grayScale-50"
|
className="h-10 rounded-[6px] border-grayScale-100 bg-white px-8 text-sm font-bold text-grayScale-600 shadow-sm hover:bg-grayScale-50"
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
|
|
@ -145,7 +159,10 @@ export function ReviewStep({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={submitting || !canPublish}
|
disabled={submitting || !canPublish}
|
||||||
onClick={onPublish}
|
onClick={() => {
|
||||||
|
setFormData({ ...formData, publishStatus: "PUBLISHED" });
|
||||||
|
onPublish();
|
||||||
|
}}
|
||||||
className="h-10 gap-3 rounded-[6px] bg-brand-500 px-10 text-sm font-bold text-white shadow-xl shadow-brand-500/20 transition-all hover:bg-brand-600 active:scale-95 disabled:opacity-50"
|
className="h-10 gap-3 rounded-[6px] bg-brand-500 px-10 text-sm font-bold text-white shadow-xl shadow-brand-500/20 transition-all hover:bg-brand-600 active:scale-95 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { Input } from "../../../../components/ui/input";
|
||||||
import { Textarea } from "../../../../components/ui/textarea";
|
import { Textarea } from "../../../../components/ui/textarea";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { uploadImageFile } from "../../../../api/files.api";
|
import { uploadImageFile } from "../../../../api/files.api";
|
||||||
|
import { PublishStatusField } from "./PublishStatusField";
|
||||||
|
import type { PracticePublishStatus } from "../../../../types/course.types";
|
||||||
|
|
||||||
interface ScenarioStepProps {
|
interface ScenarioStepProps {
|
||||||
formData: any;
|
formData: any;
|
||||||
|
|
@ -158,6 +160,13 @@ export function ScenarioStep({
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<PublishStatusField
|
||||||
|
value={(formData.publishStatus ?? "DRAFT") as PracticePublishStatus}
|
||||||
|
onChange={(publishStatus) =>
|
||||||
|
setFormData({ ...formData, publishStatus })
|
||||||
|
}
|
||||||
|
disabled={uploadingBanner}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-4">
|
<div className="flex items-center justify-between pt-4">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Check,
|
Check,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
UserX,
|
||||||
|
UserCheck,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
|
|
@ -29,6 +31,8 @@ import {
|
||||||
setRolePermissions,
|
setRolePermissions,
|
||||||
updateRole,
|
updateRole,
|
||||||
deleteRole,
|
deleteRole,
|
||||||
|
bulkDeactivateRole,
|
||||||
|
bulkReactivateRole,
|
||||||
} from "../../api/rbac.api"
|
} from "../../api/rbac.api"
|
||||||
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
|
@ -58,6 +62,13 @@ export function RolesListPage() {
|
||||||
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null)
|
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null)
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
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
|
// Role info editing state
|
||||||
const [editingRole, setEditingRole] = useState(false)
|
const [editingRole, setEditingRole] = useState(false)
|
||||||
const [editName, setEditName] = useState("")
|
const [editName, setEditName] = useState("")
|
||||||
|
|
@ -130,6 +141,39 @@ export function RolesListPage() {
|
||||||
setRoleToDelete(null)
|
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 () => {
|
const handleConfirmDeleteRole = async () => {
|
||||||
if (!roleToDelete) return
|
if (!roleToDelete) return
|
||||||
setDeleteLoading(true)
|
setDeleteLoading(true)
|
||||||
|
|
@ -421,6 +465,31 @@ export function RolesListPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs text-grayScale-700"
|
||||||
|
onClick={() => setBulkDialog({ type: "deactivate", role })}
|
||||||
|
disabled={deleteLoading || bulkActionLoading}
|
||||||
|
>
|
||||||
|
<UserX className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
Deactivate all
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs text-grayScale-700"
|
||||||
|
onClick={() => setBulkDialog({ type: "reactivate", role })}
|
||||||
|
disabled={deleteLoading || bulkActionLoading}
|
||||||
|
>
|
||||||
|
<UserCheck className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
Reactivate all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[11px] text-grayScale-400">
|
<span className="text-[11px] text-grayScale-400">
|
||||||
Open details to view permissions
|
Open details to view permissions
|
||||||
|
|
@ -783,6 +852,72 @@ export function RolesListPage() {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Bulk deactivate / reactivate confirmation */}
|
||||||
|
<Dialog
|
||||||
|
open={bulkDialog != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) handleCancelBulkDialog()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{bulkDialog?.type === "deactivate" ? (
|
||||||
|
<>
|
||||||
|
<UserX className="h-5 w-5 text-amber-600" />
|
||||||
|
Deactivate all for this role?
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserCheck className="h-5 w-5 text-brand-600" />
|
||||||
|
Reactivate all for this role?
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{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."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{bulkDialog && (
|
||||||
|
<div className="rounded-lg border border-grayScale-100 bg-grayScale-50/80 p-3">
|
||||||
|
<p className="text-sm font-semibold text-grayScale-700">{bulkDialog.role.name}</p>
|
||||||
|
<p className="text-xs text-grayScale-500">Role #{bulkDialog.role.id}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelBulkDialog}
|
||||||
|
disabled={bulkActionLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className={
|
||||||
|
bulkDialog?.type === "deactivate"
|
||||||
|
? "gap-1.5 bg-amber-600 hover:bg-amber-700"
|
||||||
|
: "gap-1.5 bg-brand-500 hover:bg-brand-600"
|
||||||
|
}
|
||||||
|
disabled={bulkActionLoading || !bulkDialog}
|
||||||
|
onClick={handleConfirmBulkAction}
|
||||||
|
>
|
||||||
|
{bulkActionLoading && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||||
|
{bulkActionLoading
|
||||||
|
? "Working…"
|
||||||
|
: bulkDialog?.type === "deactivate"
|
||||||
|
? "Deactivate all"
|
||||||
|
: "Reactivate all"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete role dialog */}
|
{/* Delete role dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,14 @@ export interface UpdateLearningProgramRequest {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateLearningProgramRequest {
|
export interface CreateLearningProgramRequest {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateLearningProgramResponse {
|
export interface CreateLearningProgramResponse {
|
||||||
|
|
@ -128,6 +130,7 @@ export interface UpdateTopLevelCourseRequest {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Body for POST /programs/:program_id/courses */
|
/** Body for POST /programs/:program_id/courses */
|
||||||
|
|
@ -135,6 +138,7 @@ export interface CreateProgramCourseRequest {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateProgramCourseResponse {
|
export interface CreateProgramCourseResponse {
|
||||||
|
|
@ -416,6 +420,7 @@ export interface UpdateTopLevelCourseModuleRequest {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
icon: string
|
icon: string
|
||||||
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Body for POST /courses/:courseId/modules */
|
/** Body for POST /courses/:courseId/modules */
|
||||||
|
|
@ -423,6 +428,7 @@ export interface CreateTopLevelCourseModuleRequest {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
icon: string
|
icon: string
|
||||||
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTopLevelCourseModuleResponse {
|
export interface CreateTopLevelCourseModuleResponse {
|
||||||
|
|
@ -468,6 +474,7 @@ export interface ParentContextPractice {
|
||||||
story_image: string
|
story_image: string
|
||||||
question_set_id: number
|
question_set_id: number
|
||||||
quick_tips: string
|
quick_tips: string
|
||||||
|
publish_status?: PracticePublishStatus | string | null
|
||||||
persona_id?: number | null
|
persona_id?: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -487,6 +494,8 @@ export interface GetPracticesByParentContextResponse {
|
||||||
|
|
||||||
export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
|
export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
|
||||||
|
|
||||||
|
export type PracticePublishStatus = "DRAFT" | "PUBLISHED"
|
||||||
|
|
||||||
/** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
|
/** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
|
||||||
export interface CreateParentLinkedPracticeRequest {
|
export interface CreateParentLinkedPracticeRequest {
|
||||||
parent_kind: PracticeParentKind
|
parent_kind: PracticeParentKind
|
||||||
|
|
@ -496,6 +505,7 @@ export interface CreateParentLinkedPracticeRequest {
|
||||||
story_image: string
|
story_image: string
|
||||||
question_set_id: number
|
question_set_id: number
|
||||||
quick_tips: string
|
quick_tips: string
|
||||||
|
publish_status: PracticePublishStatus
|
||||||
persona_id?: number
|
persona_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,14 +519,20 @@ export interface CreateParentLinkedPracticeResponse {
|
||||||
|
|
||||||
/** Body for PUT /practices/:id (Learn English parent-linked practice). */
|
/** Body for PUT /practices/:id (Learn English parent-linked practice). */
|
||||||
export interface UpdateParentLinkedPracticeRequest {
|
export interface UpdateParentLinkedPracticeRequest {
|
||||||
title: string
|
title?: string
|
||||||
story_description: string
|
story_description?: string
|
||||||
story_image: string
|
story_image?: string
|
||||||
question_set_id: number
|
question_set_id?: number
|
||||||
quick_tips: string
|
quick_tips?: string
|
||||||
|
publish_status?: PracticePublishStatus
|
||||||
persona_id?: number | null
|
persona_id?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Publish-only patch: PUT /practices/:id with { publish_status: "PUBLISHED" }. */
|
||||||
|
export interface PublishParentLinkedPracticeRequest {
|
||||||
|
publish_status: PracticePublishStatus
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateParentLinkedPracticeResponse {
|
export interface UpdateParentLinkedPracticeResponse {
|
||||||
message: string
|
message: string
|
||||||
data: ParentContextPractice
|
data: ParentContextPractice
|
||||||
|
|
|
||||||
|
|
@ -79,3 +79,31 @@ export interface GetPermissionsResponse {
|
||||||
status_code: number
|
status_code: number
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BulkRoleDeactivateData {
|
||||||
|
role: string
|
||||||
|
users_deactivated: number
|
||||||
|
team_members_deactivated: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkRoleReactivateData {
|
||||||
|
role: string
|
||||||
|
users_reactivated: number
|
||||||
|
team_members_reactivated: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkRoleDeactivateResponse {
|
||||||
|
message: string
|
||||||
|
data: BulkRoleDeactivateData
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkRoleReactivateResponse {
|
||||||
|
message: string
|
||||||
|
data: BulkRoleReactivateData
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user