feat(admin): practice edit flow, bulk notifications, and composer UX
Add full practice edit via GET/PUT .../full endpoints with question reorder and collapsible cards. Integrate bulk and scheduled SMS, email, push, and in-app notifications with a scheduled jobs page and improved recipient picker search. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
babbed323c
commit
035d73889e
|
|
@ -108,6 +108,9 @@ import type {
|
|||
CreateParentLinkedPracticeRequest,
|
||||
CreateParentLinkedPracticeResponse,
|
||||
UpdateParentLinkedPracticeRequest,
|
||||
GetPracticeFullResponse,
|
||||
UpdatePracticeFullRequest,
|
||||
UpdatePracticeFullResponse,
|
||||
UpdateParentLinkedPracticeResponse,
|
||||
PublishParentLinkedPracticeRequest,
|
||||
PublishStatusOnlyRequest,
|
||||
|
|
@ -846,6 +849,31 @@ export const updateParentLinkedPractice = (
|
|||
data: UpdateParentLinkedPracticeRequest,
|
||||
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
|
||||
|
||||
/** GET /practices/:id/full — Learn English practice with question set and questions. */
|
||||
export const getLearnEnglishPracticeFull = (practiceId: number) =>
|
||||
http.get<GetPracticeFullResponse>(`/practices/${practiceId}/full`)
|
||||
|
||||
/** PUT /practices/:id/full — atomic update of practice, question set, and questions. */
|
||||
export const updateLearnEnglishPracticeFull = (
|
||||
practiceId: number,
|
||||
data: UpdatePracticeFullRequest,
|
||||
) =>
|
||||
http.put<UpdatePracticeFullResponse>(`/practices/${practiceId}/full`, data)
|
||||
|
||||
/** GET /exam-prep/practices/:id/full */
|
||||
export const getExamPrepPracticeFull = (practiceId: number) =>
|
||||
http.get<GetPracticeFullResponse>(`/exam-prep/practices/${practiceId}/full`)
|
||||
|
||||
/** PUT /exam-prep/practices/:id/full */
|
||||
export const updateExamPrepPracticeFull = (
|
||||
practiceId: number,
|
||||
data: UpdatePracticeFullRequest,
|
||||
) =>
|
||||
http.put<UpdatePracticeFullResponse>(
|
||||
`/exam-prep/practices/${practiceId}/full`,
|
||||
data,
|
||||
)
|
||||
|
||||
/** PUT /practices/:id — set publish_status only (Learn English practice). */
|
||||
export const setLearnEnglishPracticePublishStatus = (
|
||||
practiceId: number,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import http from "./http"
|
||||
import type {
|
||||
BulkInAppRequest,
|
||||
BulkSendResult,
|
||||
BulkSmsRequest,
|
||||
GetNotificationsResponse,
|
||||
GetScheduledNotificationsParams,
|
||||
ListScheduledNotificationsResponse,
|
||||
Notification,
|
||||
ScheduledNotification,
|
||||
UnreadCountResponse,
|
||||
} from "../types/notification.types"
|
||||
import { isScheduledNotification, parseBulkResponseData } from "../lib/notificationBulk"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
|
|
@ -111,15 +118,129 @@ export const markAllRead = () =>
|
|||
export const markAllUnread = () =>
|
||||
http.post("/notifications/mark-all-unread")
|
||||
|
||||
export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) =>
|
||||
http.post("/notifications/bulk-sms", data)
|
||||
export type BulkSendApiResult =
|
||||
| { kind: "immediate"; data: BulkSendResult; message: string }
|
||||
| { kind: "scheduled"; data: ScheduledNotification; message: string }
|
||||
|
||||
export const sendBulkEmail = (formData: FormData) =>
|
||||
http.post("/notifications/bulk-email", formData, {
|
||||
function parseBulkSendApiResponse(body: unknown, status: number): BulkSendApiResult {
|
||||
const envelope = isRecord(body) ? body : {}
|
||||
const message = String(envelope.message ?? "")
|
||||
const inner = unwrapEnvelopeData(body)
|
||||
const parsed = parseBulkResponseData(inner)
|
||||
|
||||
if (isScheduledNotification(parsed) || status === 201) {
|
||||
return {
|
||||
kind: "scheduled",
|
||||
data: parsed as ScheduledNotification,
|
||||
message: message || "Notification scheduled",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "immediate",
|
||||
data: parsed as BulkSendResult,
|
||||
message: message || "Notification sent",
|
||||
}
|
||||
}
|
||||
|
||||
export const sendBulkSms = async (data: BulkSmsRequest): Promise<BulkSendApiResult> => {
|
||||
const res = await http.post("/notifications/bulk-sms", data)
|
||||
return parseBulkSendApiResponse(res.data, res.status)
|
||||
}
|
||||
|
||||
export const sendBulkEmail = async (formData: FormData): Promise<BulkSendApiResult> => {
|
||||
const res = await http.post("/notifications/bulk-email", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
})
|
||||
return parseBulkSendApiResponse(res.data, res.status)
|
||||
}
|
||||
|
||||
export const sendBulkPush = (formData: FormData) =>
|
||||
http.post("/notifications/bulk-push", formData, {
|
||||
export const sendBulkPush = async (formData: FormData): Promise<BulkSendApiResult> => {
|
||||
const res = await http.post("/notifications/bulk-push", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
})
|
||||
return parseBulkSendApiResponse(res.data, res.status)
|
||||
}
|
||||
|
||||
export const sendBulkInApp = async (data: BulkInAppRequest): Promise<BulkSendApiResult> => {
|
||||
const res = await http.post("/notifications/bulk-in-app", data)
|
||||
return parseBulkSendApiResponse(res.data, res.status)
|
||||
}
|
||||
|
||||
function normalizeScheduledNotification(raw: unknown): ScheduledNotification | null {
|
||||
if (!isRecord(raw)) return null
|
||||
const id = Number(raw.id)
|
||||
if (!id) return null
|
||||
return {
|
||||
id,
|
||||
channel: String(raw.channel ?? "") as ScheduledNotification["channel"],
|
||||
title: raw.title != null ? String(raw.title) : undefined,
|
||||
message: String(raw.message ?? ""),
|
||||
html: raw.html != null ? String(raw.html) : undefined,
|
||||
scheduled_at: String(raw.scheduled_at ?? ""),
|
||||
status: String(raw.status ?? "pending") as ScheduledNotification["status"],
|
||||
target_user_ids: Array.isArray(raw.target_user_ids)
|
||||
? raw.target_user_ids.map((v) => Number(v))
|
||||
: undefined,
|
||||
target_role: raw.target_role != null ? String(raw.target_role) : undefined,
|
||||
target_raw: isRecord(raw.target_raw)
|
||||
? {
|
||||
phones: Array.isArray(raw.target_raw.phones)
|
||||
? raw.target_raw.phones.map(String)
|
||||
: undefined,
|
||||
emails: Array.isArray(raw.target_raw.emails)
|
||||
? raw.target_raw.emails.map(String)
|
||||
: undefined,
|
||||
type: raw.target_raw.type != null ? String(raw.target_raw.type) : undefined,
|
||||
level: raw.target_raw.level != null ? String(raw.target_raw.level) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
attempt_count: raw.attempt_count != null ? Number(raw.attempt_count) : undefined,
|
||||
last_error: raw.last_error != null ? String(raw.last_error) : undefined,
|
||||
processing_started_at:
|
||||
raw.processing_started_at != null ? String(raw.processing_started_at) : null,
|
||||
sent_at: raw.sent_at != null ? String(raw.sent_at) : null,
|
||||
cancelled_at: raw.cancelled_at != null ? String(raw.cancelled_at) : null,
|
||||
created_by: raw.created_by != null ? Number(raw.created_by) : undefined,
|
||||
created_at: String(raw.created_at ?? ""),
|
||||
updated_at: String(raw.updated_at ?? ""),
|
||||
}
|
||||
}
|
||||
|
||||
function parseScheduledList(body: unknown): ListScheduledNotificationsResponse {
|
||||
const inner = unwrapEnvelopeData(body)
|
||||
if (!isRecord(inner)) {
|
||||
return { scheduled_notifications: [], total_count: 0, limit: 20, page: 1 }
|
||||
}
|
||||
|
||||
const rows = Array.isArray(inner.scheduled_notifications)
|
||||
? inner.scheduled_notifications
|
||||
: []
|
||||
const scheduled_notifications = rows
|
||||
.map(normalizeScheduledNotification)
|
||||
.filter((row): row is ScheduledNotification => row !== null)
|
||||
|
||||
return {
|
||||
scheduled_notifications,
|
||||
total_count: Number(inner.total_count ?? scheduled_notifications.length),
|
||||
limit: Number(inner.limit ?? 20),
|
||||
page: Number(inner.page ?? 1),
|
||||
}
|
||||
}
|
||||
|
||||
export const getScheduledNotifications = (params: GetScheduledNotificationsParams = {}) =>
|
||||
http
|
||||
.get<unknown>("/notifications/scheduled", { params })
|
||||
.then((res) => ({ ...res, data: parseScheduledList(res.data) }))
|
||||
|
||||
export const getScheduledNotificationById = (id: number) =>
|
||||
http.get<unknown>(`/notifications/scheduled/${id}`).then((res) => ({
|
||||
...res,
|
||||
data: normalizeScheduledNotification(unwrapEnvelopeData(res.data)),
|
||||
}))
|
||||
|
||||
export const cancelScheduledNotification = (id: number) =>
|
||||
http.post<unknown>(`/notifications/scheduled/${id}/cancel`).then((res) => ({
|
||||
...res,
|
||||
data: normalizeScheduledNotification(unwrapEnvelopeData(res.data)),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { LessonPracticesPage } from "../pages/content-management/LessonPractices
|
|||
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
|
||||
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
|
||||
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
|
||||
import { EditPracticeFlow } from "../pages/content-management/EditPracticeFlow";
|
||||
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
|
||||
import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
|
||||
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
|
||||
|
|
@ -33,6 +34,7 @@ import { CreateQuestionTypeFlow } from "../pages/content-management/CreateQuesti
|
|||
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
||||
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
|
||||
import { ScheduledNotificationsPage } from "../pages/notifications/ScheduledNotificationsPage";
|
||||
import { EmailTemplatesPage } from "../pages/notifications/EmailTemplatesPage";
|
||||
import { EmailTemplateDetailPage } from "../pages/notifications/EmailTemplateDetailPage";
|
||||
import { CreateEmailTemplatePage } from "../pages/notifications/CreateEmailTemplatePage";
|
||||
|
|
@ -216,6 +218,10 @@ export function AppRoutes() {
|
|||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/practices"
|
||||
element={<LessonPracticesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/edit-practice/:practiceId"
|
||||
element={<EditPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english"
|
||||
element={<LearnEnglishPage />}
|
||||
|
|
@ -240,6 +246,18 @@ export function AppRoutes() {
|
|||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/practices"
|
||||
element={<LessonPracticesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english/:level/courses/:courseId/edit-practice/:practiceId"
|
||||
element={<EditPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/edit-practice/:practiceId"
|
||||
element={<EditPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/edit-practice/:practiceId"
|
||||
element={<EditPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english/:level/courses/add-practice"
|
||||
element={<AddPracticeFlow />}
|
||||
|
|
@ -262,6 +280,10 @@ export function AppRoutes() {
|
|||
path="/notifications/create"
|
||||
element={<CreateNotificationPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications/scheduled"
|
||||
element={<ScheduledNotificationsPage />}
|
||||
/>
|
||||
<Route path="/payments" element={<PaymentsPage />} />
|
||||
<Route path="/user-log" element={<UserLogPage />} />
|
||||
<Route path="/issues" element={<IssuesPage />} />
|
||||
|
|
|
|||
173
src/components/notifications/NotificationSchedulePicker.tsx
Normal file
173
src/components/notifications/NotificationSchedulePicker.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { useMemo, useState } from "react"
|
||||
import { Calendar, Clock3 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { formatScheduledAtLabel, toRfc3339Utc } from "../../lib/notificationBulk"
|
||||
|
||||
function digitsOnly(value: string, maxLength: number) {
|
||||
return value.replace(/\D/g, "").slice(0, maxLength)
|
||||
}
|
||||
|
||||
type NotificationSchedulePickerProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NotificationSchedulePicker({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: NotificationSchedulePickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [year, setYear] = useState("")
|
||||
const [month, setMonth] = useState("")
|
||||
const [day, setDay] = useState("")
|
||||
const [hour, setHour] = useState("")
|
||||
const [minute, setMinute] = useState("")
|
||||
|
||||
const label = useMemo(() => formatScheduledAtLabel(value), [value])
|
||||
|
||||
const clearFields = () => {
|
||||
setYear("")
|
||||
setMonth("")
|
||||
setDay("")
|
||||
setHour("")
|
||||
setMinute("")
|
||||
onChange("")
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-grayScale-50/70 px-3 text-sm text-grayScale-700 shadow-sm transition-all",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-100",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">{label}</span>
|
||||
<span className="ml-2 inline-flex items-center gap-1 rounded-md border border-grayScale-200 bg-white px-2 py-1 text-[11px] text-grayScale-500">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[320px] p-3">
|
||||
<p className="mb-2 text-xs font-semibold text-grayScale-500">Schedule notification</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Date</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="YYYY"
|
||||
value={year}
|
||||
onChange={(e) => setYear(digitsOnly(e.target.value, 4))}
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
className="h-9 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
||||
/>
|
||||
<span className="text-grayScale-400">-</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="MM"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(digitsOnly(e.target.value, 2))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
||||
/>
|
||||
<span className="text-grayScale-400">-</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="DD"
|
||||
value={day}
|
||||
onChange={(e) => setDay(digitsOnly(e.target.value, 2))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Time (UTC)</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="HH"
|
||||
value={hour}
|
||||
onChange={(e) => setHour(digitsOnly(e.target.value, 2))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
||||
/>
|
||||
<span className="text-grayScale-400">:</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="MM"
|
||||
value={minute}
|
||||
onChange={(e) => setMinute(digitsOnly(e.target.value, 2))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
const now = new Date()
|
||||
setYear(String(now.getUTCFullYear()))
|
||||
setMonth(String(now.getUTCMonth() + 1).padStart(2, "0"))
|
||||
setDay(String(now.getUTCDate()).padStart(2, "0"))
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8" onClick={clearFields}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
const rfc3339 = toRfc3339Utc(year, month, day, hour, minute)
|
||||
if (!rfc3339) {
|
||||
toast.error("Invalid schedule time", {
|
||||
description: "Use YYYY-MM-DD and HH:MM (24h UTC). Time must be in the future.",
|
||||
})
|
||||
return
|
||||
}
|
||||
onChange(rfc3339)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -88,6 +88,7 @@ const navEntries: NavEntry[] = [
|
|||
{ label: "Inbox", to: "/notifications", end: true },
|
||||
{ label: "Email templates", to: "/notifications/email-templates" },
|
||||
{ label: "Send notification", to: "/notifications/create" },
|
||||
{ label: "Scheduled", to: "/notifications/scheduled" },
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function AppLayout() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-grayScale-100">
|
||||
<div className="flex h-dvh min-h-screen overflow-hidden bg-grayScale-100">
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
|
|
@ -68,15 +68,18 @@ export function AppLayout() {
|
|||
onClose={handleSidebarClose}
|
||||
/>
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 flex-col transition-[margin] duration-300 ${
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-[margin] duration-300 ${
|
||||
sidebarCollapsed ? "lg:ml-[88px]" : "lg:ml-[264px]"
|
||||
}`}
|
||||
>
|
||||
<Topbar onSidebarToggle={handleSidebarToggle} />
|
||||
<main ref={mainRef} className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6">
|
||||
<main
|
||||
ref={mainRef}
|
||||
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 pb-8 pt-4 sm:px-4 lg:px-6"
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">
|
||||
<footer className="shrink-0 border-t bg-grayScale-50 px-4 py-3 lg:px-6">
|
||||
<div className="flex items-center justify-center gap-1.5 text-xs text-grayScale-400">
|
||||
<span>Powered by</span>
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export interface LearnEnglishDefinitionQuestionInput {
|
|||
dynamicFieldValues: Record<string, string>
|
||||
difficultyLevel?: QuestionDifficultyLevel
|
||||
points?: number
|
||||
displayOrder?: number
|
||||
mcqOptions?: { option_text: string; is_correct: boolean }[]
|
||||
trueFalseAnswerIsTrue?: boolean
|
||||
shortAnswers?: string[]
|
||||
|
|
|
|||
173
src/lib/notificationBulk.ts
Normal file
173
src/lib/notificationBulk.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import type { UserApiDTO } from "../types/user.types"
|
||||
import type {
|
||||
BulkSendResult,
|
||||
PlatformRole,
|
||||
ScheduledNotification,
|
||||
} from "../types/notification.types"
|
||||
import { getUsers } from "../api/users.api"
|
||||
|
||||
export const PLATFORM_ROLES: { value: PlatformRole; label: string }[] = [
|
||||
{ value: "STUDENT", label: "Students" },
|
||||
{ value: "OPEN_LEARNER", label: "Open learners" },
|
||||
{ value: "INSTRUCTOR", label: "Instructors" },
|
||||
{ value: "ADMIN", label: "Admins" },
|
||||
{ value: "SUPER_ADMIN", label: "Super admins" },
|
||||
{ value: "SUPPORT", label: "Support" },
|
||||
]
|
||||
|
||||
export const IN_APP_TYPES = [
|
||||
{ value: "system_alert", label: "System alert" },
|
||||
{ value: "subscription_expiring", label: "Subscription expiring" },
|
||||
{ value: "course_completed", label: "Course completed" },
|
||||
{ value: "payment_verified", label: "Payment verified" },
|
||||
] as const
|
||||
|
||||
export const IN_APP_LEVELS = [
|
||||
{ value: "info", label: "Info" },
|
||||
{ value: "warning", label: "Warning" },
|
||||
{ value: "error", label: "Error" },
|
||||
{ value: "success", label: "Success" },
|
||||
] as const
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function isScheduledNotification(value: unknown): value is ScheduledNotification {
|
||||
if (!isRecord(value)) return false
|
||||
return typeof value.id === "number" && typeof value.scheduled_at === "string"
|
||||
}
|
||||
|
||||
export function parseBulkResponseData(data: unknown): BulkSendResult | ScheduledNotification {
|
||||
if (isScheduledNotification(data)) return data
|
||||
if (!isRecord(data)) {
|
||||
return { sent: 0, failed: 0 }
|
||||
}
|
||||
return {
|
||||
total_recipients:
|
||||
data.total_recipients != null ? Number(data.total_recipients) : undefined,
|
||||
sent: Number(data.sent ?? 0),
|
||||
failed: Number(data.failed ?? 0),
|
||||
target_users: data.target_users != null ? Number(data.target_users) : undefined,
|
||||
image: data.image != null ? String(data.image) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function toRfc3339Utc(
|
||||
year: string,
|
||||
month: string,
|
||||
day: string,
|
||||
hour: string,
|
||||
minute: string,
|
||||
): string | null {
|
||||
const y = Number(year)
|
||||
const m = Number(month)
|
||||
const d = Number(day)
|
||||
const h = Number(hour)
|
||||
const min = Number(minute)
|
||||
|
||||
const formatOk =
|
||||
year.length === 4 &&
|
||||
month.length === 2 &&
|
||||
day.length === 2 &&
|
||||
hour.length === 2 &&
|
||||
minute.length === 2
|
||||
const dateValue = new Date(y, m - 1, d)
|
||||
const dateOk =
|
||||
formatOk &&
|
||||
m >= 1 &&
|
||||
m <= 12 &&
|
||||
d >= 1 &&
|
||||
d <= 31 &&
|
||||
dateValue.getFullYear() === y &&
|
||||
dateValue.getMonth() === m - 1 &&
|
||||
dateValue.getDate() === d
|
||||
const timeOk = h >= 0 && h <= 23 && min >= 0 && min <= 59
|
||||
|
||||
if (!dateOk || !timeOk) return null
|
||||
|
||||
const utc = new Date(Date.UTC(y, m - 1, d, h, min, 0, 0))
|
||||
if (utc.getTime() <= Date.now()) return null
|
||||
return utc.toISOString()
|
||||
}
|
||||
|
||||
export function parseDirectRecipients(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function formatScheduledAtLabel(value: string): string {
|
||||
if (!value) return "Set date & time"
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return value
|
||||
return parsed.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function scheduledStatusBadgeVariant(
|
||||
status: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "secondary"
|
||||
case "processing":
|
||||
return "default"
|
||||
case "sent":
|
||||
return "default"
|
||||
case "failed":
|
||||
return "destructive"
|
||||
case "cancelled":
|
||||
return "outline"
|
||||
default:
|
||||
return "outline"
|
||||
}
|
||||
}
|
||||
|
||||
export function channelLabel(channel: string): string {
|
||||
switch (channel) {
|
||||
case "sms":
|
||||
return "SMS"
|
||||
case "email":
|
||||
return "Email"
|
||||
case "push":
|
||||
return "Push"
|
||||
case "in_app":
|
||||
return "In-app"
|
||||
default:
|
||||
return channel
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllPlatformUsers(): Promise<UserApiDTO[]> {
|
||||
const pageSize = 50
|
||||
const firstRes = await getUsers({ page: 1, page_size: pageSize })
|
||||
const firstBatch = firstRes.data?.data?.users ?? []
|
||||
const total = firstRes.data?.data?.total ?? firstBatch.length
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
if (totalPages <= 1) return firstBatch
|
||||
|
||||
const remaining = await Promise.all(
|
||||
Array.from({ length: totalPages - 1 }, (_, i) =>
|
||||
getUsers({ page: i + 2, page_size: pageSize }),
|
||||
),
|
||||
)
|
||||
const rest = remaining.flatMap((r) => r.data?.data?.users ?? [])
|
||||
return [...firstBatch, ...rest]
|
||||
}
|
||||
|
||||
export function extractApiErrorMessage(err: unknown, fallback: string): string {
|
||||
if (isRecord(err) && isRecord(err.response) && isRecord(err.response.data)) {
|
||||
const data = err.response.data
|
||||
if (typeof data.message === "string" && data.message) return data.message
|
||||
if (typeof data.error === "string" && data.error) return data.error
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
|
@ -104,14 +104,23 @@ export async function executePracticeCreation(
|
|||
})
|
||||
const setId = extractCreatedResourceId(setRes, "Could not create question set")
|
||||
|
||||
const toCreate = opts.questions.filter((q) => {
|
||||
const def = byId.get(q.questionTypeDefinitionId)
|
||||
return def ? questionRowHasContent(q, def) : false
|
||||
})
|
||||
const toCreate = opts.questions
|
||||
.map((q, index) => ({
|
||||
q,
|
||||
sortOrder:
|
||||
Number.isFinite(q.displayOrder) && (q.displayOrder ?? 0) > 0
|
||||
? Number(q.displayOrder)
|
||||
: index + 1,
|
||||
}))
|
||||
.filter(({ q }) => {
|
||||
const def = byId.get(q.questionTypeDefinitionId)
|
||||
return def ? questionRowHasContent(q, def) : false
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
// Steps 2 & 3 — create questions and attach to set
|
||||
// Steps 2 & 3 — create questions and attach to set (order from step 3 drag-and-drop)
|
||||
let displayOrder = 0
|
||||
for (const q of toCreate) {
|
||||
for (const { q } of toCreate) {
|
||||
const def = byId.get(q.questionTypeDefinitionId)
|
||||
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
|
||||
displayOrder += 1
|
||||
|
|
|
|||
46
src/lib/practiceEditOrchestrator.ts
Normal file
46
src/lib/practiceEditOrchestrator.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import {
|
||||
updateExamPrepPracticeFull,
|
||||
updateLearnEnglishPracticeFull,
|
||||
} from "../api/courses.api"
|
||||
import type { PracticePublishStatus } from "../types/course.types"
|
||||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||
import {
|
||||
buildPracticeFullUpdateRequest,
|
||||
type PracticeFormState,
|
||||
type PreservedQuestionSetFields,
|
||||
type PracticeEditQuestionInput,
|
||||
} from "./practiceFullMapper"
|
||||
|
||||
export interface PracticeEditInput {
|
||||
practiceId: number
|
||||
isExamPrep: boolean
|
||||
status: PracticePublishStatus
|
||||
formData: PracticeFormState
|
||||
personaId: number
|
||||
preservedQuestionSet: PreservedQuestionSetFields
|
||||
questions: PracticeEditQuestionInput[]
|
||||
definitions: QuestionTypeDefinition[]
|
||||
isLearnEnglishLessonPractice: boolean
|
||||
lessonDefaultTitle?: string
|
||||
}
|
||||
|
||||
export async function executePracticeUpdate(
|
||||
opts: PracticeEditInput,
|
||||
): Promise<void> {
|
||||
const payload = buildPracticeFullUpdateRequest({
|
||||
formData: opts.formData,
|
||||
personaId: opts.personaId,
|
||||
status: opts.status,
|
||||
preservedQuestionSet: opts.preservedQuestionSet,
|
||||
questions: opts.questions,
|
||||
definitions: opts.definitions,
|
||||
isLearnEnglishLessonPractice: opts.isLearnEnglishLessonPractice,
|
||||
lessonDefaultTitle: opts.lessonDefaultTitle,
|
||||
})
|
||||
|
||||
if (opts.isExamPrep) {
|
||||
await updateExamPrepPracticeFull(opts.practiceId, payload)
|
||||
return
|
||||
}
|
||||
await updateLearnEnglishPracticeFull(opts.practiceId, payload)
|
||||
}
|
||||
711
src/lib/practiceFullMapper.ts
Normal file
711
src/lib/practiceFullMapper.ts
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
import type {
|
||||
GetPracticeFullResponse,
|
||||
PracticeFullData,
|
||||
PracticeFullPractice,
|
||||
PracticeFullQuestionItem,
|
||||
PracticeFullQuestionSet,
|
||||
PracticePublishStatus,
|
||||
QuestionOption,
|
||||
QuestionShortAnswer,
|
||||
UpdatePracticeFullRequest,
|
||||
} from "../types/course.types"
|
||||
import type {
|
||||
DynamicElementInstance,
|
||||
DynamicQuestionPayload,
|
||||
QuestionTypeDefinition,
|
||||
} from "../types/questionTypeDefinition.types"
|
||||
import {
|
||||
buildCreateQuestionFromDefinition,
|
||||
definitionUsesDynamicPayload,
|
||||
dynamicPromptFromFieldValues,
|
||||
emptyDynamicFieldValuesForDefinition,
|
||||
legacyQuestionTypeFromDefinition,
|
||||
questionRowHasContent,
|
||||
type LearnEnglishDefinitionQuestionInput,
|
||||
} from "./learnEnglishDefinitionQuestion"
|
||||
import { serializeMultipleChoiceSlotValue } from "./multipleChoiceSlotValue"
|
||||
import { validatePracticeQuestionsWithDefinitions } from "./practiceCreationOrchestrator"
|
||||
|
||||
export interface PracticeFormQuestionRow {
|
||||
id: string
|
||||
serverQuestionId?: number | null
|
||||
displayOrder: number
|
||||
questionTypeDefinitionId: number | null
|
||||
text: string
|
||||
difficultyLevel: "EASY" | "MEDIUM" | "HARD"
|
||||
points: number
|
||||
dynamicFieldValues: Record<string, string>
|
||||
mcqOptions: { text: string; isCorrect: boolean }[]
|
||||
trueFalseCorrect: boolean
|
||||
shortAnswers: string[]
|
||||
}
|
||||
|
||||
export interface PracticeFormState {
|
||||
title: string
|
||||
description: string
|
||||
storyImageUrl: string
|
||||
shuffleQuestions: boolean
|
||||
tips: string
|
||||
questions: PracticeFormQuestionRow[]
|
||||
}
|
||||
|
||||
export interface PreservedQuestionSetFields {
|
||||
timeLimitMinutes: number | null
|
||||
passingScore: number | null
|
||||
introVideoUrl: string
|
||||
status: PracticePublishStatus
|
||||
}
|
||||
|
||||
function defaultMcqOptions() {
|
||||
return [
|
||||
{ text: "", isCorrect: true },
|
||||
{ text: "", isCorrect: false },
|
||||
{ text: "", isCorrect: false },
|
||||
{ text: "", isCorrect: false },
|
||||
]
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function pickStr(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
if (value == null) continue
|
||||
const text = String(value).trim()
|
||||
if (text) return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function pickNum(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = Number(record[key])
|
||||
if (Number.isFinite(value)) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isTextPromptKind(kind: string): boolean {
|
||||
const upper = kind.trim().toUpperCase()
|
||||
return (
|
||||
upper === "QUESTION_TEXT" ||
|
||||
upper === "INSTRUCTION" ||
|
||||
upper === "TEXT_PASSAGE" ||
|
||||
upper === "TEXT"
|
||||
)
|
||||
}
|
||||
|
||||
function isAudioKind(kind: string): boolean {
|
||||
const upper = kind.trim().toUpperCase()
|
||||
return upper.includes("AUDIO") || upper.includes("VOICE")
|
||||
}
|
||||
|
||||
function normalizeDynamicElement(raw: unknown): DynamicElementInstance | null {
|
||||
const record = asRecord(raw)
|
||||
if (!record) return null
|
||||
const id = pickStr(record, "id", "Id", "ID")
|
||||
const kind = pickStr(record, "kind", "Kind")
|
||||
if (!id && !kind) return null
|
||||
return {
|
||||
id: id || kind.toLowerCase(),
|
||||
kind,
|
||||
value: record.value ?? record.Value,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDynamicElementArray(raw: unknown): DynamicElementInstance[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((entry) => normalizeDynamicElement(entry))
|
||||
.filter((entry): entry is DynamicElementInstance => entry != null)
|
||||
}
|
||||
|
||||
function normalizeDynamicPayload(raw: unknown): DynamicQuestionPayload | null {
|
||||
const record = asRecord(raw)
|
||||
if (!record) return null
|
||||
return {
|
||||
stimulus: normalizeDynamicElementArray(record.stimulus ?? record.Stimulus),
|
||||
response: normalizeDynamicElementArray(record.response ?? record.Response),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQuestionOptions(raw: unknown): QuestionOption[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined
|
||||
const options = raw
|
||||
.map((entry) => {
|
||||
const record = asRecord(entry)
|
||||
if (!record) return null
|
||||
return {
|
||||
option_order:
|
||||
pickNum(record, "option_order", "OptionOrder", "order", "Order") ?? 0,
|
||||
option_text: pickStr(
|
||||
record,
|
||||
"option_text",
|
||||
"OptionText",
|
||||
"text",
|
||||
"Text",
|
||||
),
|
||||
is_correct: Boolean(record.is_correct ?? record.IsCorrect),
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is QuestionOption => entry != null)
|
||||
return options.length > 0 ? options : undefined
|
||||
}
|
||||
|
||||
function normalizeShortAnswerItems(
|
||||
raw: unknown,
|
||||
): QuestionShortAnswer[] | string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined
|
||||
const items = raw
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") return entry
|
||||
const record = asRecord(entry)
|
||||
if (!record) return null
|
||||
const acceptable = pickStr(
|
||||
record,
|
||||
"acceptable_answer",
|
||||
"AcceptableAnswer",
|
||||
"answer",
|
||||
"Answer",
|
||||
)
|
||||
if (!acceptable) return null
|
||||
const matchType = pickStr(record, "match_type", "MatchType") || "CASE_INSENSITIVE"
|
||||
return { acceptable_answer: acceptable, match_type: matchType }
|
||||
})
|
||||
.filter(
|
||||
(entry): entry is QuestionShortAnswer | string =>
|
||||
entry != null && (typeof entry === "string" || Boolean(entry.acceptable_answer)),
|
||||
)
|
||||
return items.length > 0 ? items : undefined
|
||||
}
|
||||
|
||||
export function normalizePracticeFullQuestion(
|
||||
raw: unknown,
|
||||
): PracticeFullQuestionItem | null {
|
||||
const record = asRecord(raw)
|
||||
if (!record) return null
|
||||
const id = pickNum(record, "id", "Id", "ID")
|
||||
const displayOrder =
|
||||
pickNum(record, "display_order", "DisplayOrder", "displayOrder") ?? 0
|
||||
const questionType =
|
||||
pickStr(record, "question_type", "QuestionType", "questionType") || "DYNAMIC"
|
||||
|
||||
return {
|
||||
...(id != null ? { id } : {}),
|
||||
display_order: displayOrder,
|
||||
question_text: pickStr(record, "question_text", "QuestionText", "questionText") || undefined,
|
||||
question_type: questionType,
|
||||
question_type_definition_id:
|
||||
pickNum(
|
||||
record,
|
||||
"question_type_definition_id",
|
||||
"QuestionTypeDefinitionId",
|
||||
"questionTypeDefinitionId",
|
||||
) ?? null,
|
||||
dynamic_payload: normalizeDynamicPayload(
|
||||
record.dynamic_payload ?? record.DynamicPayload,
|
||||
),
|
||||
difficulty_level:
|
||||
pickStr(record, "difficulty_level", "DifficultyLevel", "difficultyLevel") ||
|
||||
undefined,
|
||||
points: pickNum(record, "points", "Points") ?? undefined,
|
||||
status:
|
||||
pickStr(record, "status", "Status") ||
|
||||
undefined,
|
||||
options: normalizeQuestionOptions(record.options ?? record.Options),
|
||||
short_answers: normalizeShortAnswerItems(
|
||||
record.short_answers ?? record.ShortAnswers,
|
||||
),
|
||||
voice_prompt:
|
||||
pickStr(record, "voice_prompt", "VoicePrompt", "voicePrompt") || undefined,
|
||||
sample_answer_voice_prompt:
|
||||
pickStr(
|
||||
record,
|
||||
"sample_answer_voice_prompt",
|
||||
"SampleAnswerVoicePrompt",
|
||||
"sampleAnswerVoicePrompt",
|
||||
) || undefined,
|
||||
audio_correct_answer_text:
|
||||
pickStr(
|
||||
record,
|
||||
"audio_correct_answer_text",
|
||||
"AudioCorrectAnswerText",
|
||||
"audioCorrectAnswerText",
|
||||
) || undefined,
|
||||
image_url: pickStr(record, "image_url", "ImageUrl", "imageUrl") || undefined,
|
||||
tips: pickStr(record, "tips", "Tips") || undefined,
|
||||
explanation: pickStr(record, "explanation", "Explanation") || undefined,
|
||||
created_at: pickStr(record, "created_at", "CreatedAt", "createdAt") || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePracticeFullQuestionSet(raw: unknown): PracticeFullQuestionSet | null {
|
||||
const record = asRecord(raw)
|
||||
if (!record) return null
|
||||
const id = pickNum(record, "id", "Id", "ID")
|
||||
if (id == null) return null
|
||||
return {
|
||||
id,
|
||||
title: pickStr(record, "title", "Title"),
|
||||
description:
|
||||
pickStr(record, "description", "Description") ||
|
||||
null,
|
||||
set_type: pickStr(record, "set_type", "SetType", "setType") || undefined,
|
||||
owner_type: pickStr(record, "owner_type", "OwnerType", "ownerType") || undefined,
|
||||
owner_id: pickNum(record, "owner_id", "OwnerId", "ownerId") ?? undefined,
|
||||
persona: pickStr(record, "persona", "Persona") || null,
|
||||
shuffle_questions: Boolean(
|
||||
record.shuffle_questions ?? record.ShuffleQuestions ?? false,
|
||||
),
|
||||
status: pickStr(record, "status", "Status") || undefined,
|
||||
time_limit_minutes:
|
||||
pickNum(record, "time_limit_minutes", "TimeLimitMinutes", "timeLimitMinutes"),
|
||||
passing_score: pickNum(record, "passing_score", "PassingScore", "passingScore"),
|
||||
intro_video_url:
|
||||
pickStr(record, "intro_video_url", "IntroVideoUrl", "introVideoUrl") || null,
|
||||
question_count: pickNum(record, "question_count", "QuestionCount", "questionCount") ?? undefined,
|
||||
created_at: pickStr(record, "created_at", "CreatedAt", "createdAt") || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePracticeFullPractice(raw: unknown): PracticeFullPractice | null {
|
||||
const record = asRecord(raw)
|
||||
if (!record) return null
|
||||
const id = pickNum(record, "id", "Id", "ID")
|
||||
const questionSetId = pickNum(
|
||||
record,
|
||||
"question_set_id",
|
||||
"QuestionSetId",
|
||||
"questionSetId",
|
||||
)
|
||||
if (id == null || questionSetId == null) return null
|
||||
return {
|
||||
id,
|
||||
title: pickStr(record, "title", "Title"),
|
||||
story_description:
|
||||
pickStr(record, "story_description", "StoryDescription", "storyDescription") ||
|
||||
undefined,
|
||||
story_image:
|
||||
pickStr(record, "story_image", "StoryImage", "storyImage") || undefined,
|
||||
persona_id: pickNum(record, "persona_id", "PersonaId", "personaId"),
|
||||
question_set_id: questionSetId,
|
||||
publish_status:
|
||||
pickStr(record, "publish_status", "PublishStatus", "publishStatus") || null,
|
||||
quick_tips: pickStr(record, "quick_tips", "QuickTips", "quickTips") || undefined,
|
||||
lesson_id: pickNum(record, "lesson_id", "LessonId", "lessonId") ?? undefined,
|
||||
parent_kind: pickStr(record, "parent_kind", "ParentKind", "parentKind") || undefined,
|
||||
parent_id: pickNum(record, "parent_id", "ParentId", "parentId") ?? undefined,
|
||||
created_at: pickStr(record, "created_at", "CreatedAt", "createdAt") || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePracticeFullData(raw: unknown): PracticeFullData | null {
|
||||
const record = asRecord(raw)
|
||||
if (!record) return null
|
||||
const practice = normalizePracticeFullPractice(record.practice ?? record.Practice)
|
||||
const questionSet = normalizePracticeFullQuestionSet(
|
||||
record.question_set ?? record.QuestionSet ?? record.questionSet,
|
||||
)
|
||||
const questionsRaw = record.questions ?? record.Questions
|
||||
if (!practice || !questionSet || !Array.isArray(questionsRaw)) return null
|
||||
const questions = questionsRaw
|
||||
.map((entry) => normalizePracticeFullQuestion(entry))
|
||||
.filter((entry): entry is PracticeFullQuestionItem => entry != null)
|
||||
return { practice, question_set: questionSet, questions }
|
||||
}
|
||||
|
||||
function payloadValuesByKind(
|
||||
payload: DynamicQuestionPayload | null | undefined,
|
||||
side: "stimulus" | "response",
|
||||
): Map<string, unknown> {
|
||||
const map = new Map<string, unknown>()
|
||||
const slots = side === "stimulus" ? payload?.stimulus : payload?.response
|
||||
for (const slot of slots ?? []) {
|
||||
const kind = (slot.kind ?? "").trim().toUpperCase()
|
||||
if (!kind || map.has(kind)) continue
|
||||
map.set(kind, slot.value)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function hydrateDynamicFieldValues(
|
||||
def: QuestionTypeDefinition,
|
||||
question: PracticeFullQuestionItem,
|
||||
fieldValues: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const merged = { ...fieldValues }
|
||||
const stimulusByKind = payloadValuesByKind(question.dynamic_payload, "stimulus")
|
||||
const responseByKind = payloadValuesByKind(question.dynamic_payload, "response")
|
||||
const questionText = String(question.question_text ?? "").trim()
|
||||
const voicePrompt = String(question.voice_prompt ?? "").trim()
|
||||
const sampleAnswerVoice = String(question.sample_answer_voice_prompt ?? "").trim()
|
||||
|
||||
for (const row of def.stimulus_schema) {
|
||||
const key = `stimulus:${row.id}`
|
||||
if (merged[key]?.trim()) continue
|
||||
const kind = row.kind.trim().toUpperCase()
|
||||
if (questionText && isTextPromptKind(kind)) {
|
||||
merged[key] = questionText
|
||||
continue
|
||||
}
|
||||
if (voicePrompt && isAudioKind(kind)) {
|
||||
merged[key] = voicePrompt
|
||||
continue
|
||||
}
|
||||
if (stimulusByKind.has(kind)) {
|
||||
merged[key] = slotApiValueToFieldString(stimulusByKind.get(kind), row.kind)
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of def.response_schema) {
|
||||
const key = `response:${row.id}`
|
||||
if (merged[key]?.trim()) continue
|
||||
const kind = row.kind.trim().toUpperCase()
|
||||
if (sampleAnswerVoice && isAudioKind(kind)) {
|
||||
merged[key] = sampleAnswerVoice
|
||||
continue
|
||||
}
|
||||
if (responseByKind.has(kind)) {
|
||||
merged[key] = slotApiValueToFieldString(responseByKind.get(kind), row.kind)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function slotApiValueToFieldString(value: unknown, kind: string): string {
|
||||
const upperKind = kind.trim().toUpperCase()
|
||||
if (value == null) return ""
|
||||
if (typeof value === "string") return value
|
||||
if (upperKind === "PREP_TIME" || upperKind === "ANSWER_TIMER") {
|
||||
if (typeof value === "object" && value !== null && "seconds" in value) {
|
||||
const seconds = (value as { seconds?: unknown }).seconds
|
||||
if (typeof seconds === "number" && Number.isFinite(seconds)) {
|
||||
return String(seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (upperKind === "MULTIPLE_CHOICE" || upperKind === "OPTION") {
|
||||
return serializeMultipleChoiceSlotValue(value as { options: unknown[] })
|
||||
}
|
||||
if (typeof value === "object") return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function dynamicPayloadToFieldValues(
|
||||
payload: DynamicQuestionPayload | null | undefined,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const slot of payload?.stimulus ?? []) {
|
||||
out[`stimulus:${slot.id}`] = slotApiValueToFieldString(slot.value, slot.kind)
|
||||
}
|
||||
for (const slot of payload?.response ?? []) {
|
||||
out[`response:${slot.id}`] = slotApiValueToFieldString(slot.value, slot.kind)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeShortAnswers(
|
||||
shortAnswers: PracticeFullQuestionItem["short_answers"],
|
||||
): string[] {
|
||||
if (!shortAnswers?.length) return [""]
|
||||
const lines = shortAnswers
|
||||
.map((entry) =>
|
||||
typeof entry === "string" ? entry : entry.acceptable_answer,
|
||||
)
|
||||
.map((s) => String(s ?? "").trim())
|
||||
.filter(Boolean)
|
||||
return lines.length > 0 ? lines : [""]
|
||||
}
|
||||
|
||||
function mapFullQuestionToFormRow(
|
||||
rawQuestion: PracticeFullQuestionItem,
|
||||
typeDefinitions: QuestionTypeDefinition[],
|
||||
): PracticeFormQuestionRow {
|
||||
const q = normalizePracticeFullQuestion(rawQuestion) ?? rawQuestion
|
||||
const defId = q.question_type_definition_id ?? null
|
||||
const def = defId
|
||||
? typeDefinitions.find((d) => d.id === defId)
|
||||
: undefined
|
||||
|
||||
let dynamicFieldValues: Record<string, string> = {}
|
||||
if (def) {
|
||||
dynamicFieldValues = hydrateDynamicFieldValues(def, q, {
|
||||
...emptyDynamicFieldValuesForDefinition(def),
|
||||
...dynamicPayloadToFieldValues(q.dynamic_payload),
|
||||
})
|
||||
} else if (q.dynamic_payload) {
|
||||
dynamicFieldValues = dynamicPayloadToFieldValues(q.dynamic_payload)
|
||||
}
|
||||
|
||||
let text = String(q.question_text ?? "").trim()
|
||||
let mcqOptions = defaultMcqOptions()
|
||||
let trueFalseCorrect = true
|
||||
let shortAnswers = [""]
|
||||
|
||||
if (def && definitionUsesDynamicPayload(def)) {
|
||||
if (!text) text = dynamicPromptFromFieldValues(def, dynamicFieldValues)
|
||||
} else if (q.question_type === "MCQ" || q.question_type === "MULTIPLE_CHOICE") {
|
||||
const opts = q.options ?? []
|
||||
mcqOptions =
|
||||
opts.length > 0
|
||||
? opts.map((o) => ({
|
||||
text: o.option_text ?? "",
|
||||
isCorrect: Boolean(o.is_correct),
|
||||
}))
|
||||
: defaultMcqOptions()
|
||||
} else if (q.question_type === "TRUE_FALSE") {
|
||||
const correct = q.options?.find((o) => o.is_correct)
|
||||
trueFalseCorrect =
|
||||
correct?.option_text?.trim().toLowerCase() !== "false"
|
||||
} else if (
|
||||
q.question_type === "SHORT_ANSWER" ||
|
||||
q.question_type === "SHORT"
|
||||
) {
|
||||
shortAnswers = normalizeShortAnswers(q.short_answers)
|
||||
} else if (def) {
|
||||
const legacy = legacyQuestionTypeFromDefinition(def)
|
||||
if (legacy === "MCQ") {
|
||||
const opts = q.options ?? []
|
||||
mcqOptions =
|
||||
opts.length > 0
|
||||
? opts.map((o) => ({
|
||||
text: o.option_text ?? "",
|
||||
isCorrect: Boolean(o.is_correct),
|
||||
}))
|
||||
: defaultMcqOptions()
|
||||
} else if (legacy === "TRUE_FALSE") {
|
||||
const correct = q.options?.find((o) => o.is_correct)
|
||||
trueFalseCorrect =
|
||||
correct?.option_text?.trim().toLowerCase() !== "false"
|
||||
} else if (legacy === "SHORT_ANSWER") {
|
||||
shortAnswers = normalizeShortAnswers(q.short_answers)
|
||||
}
|
||||
}
|
||||
|
||||
const difficulty = String(q.difficulty_level ?? "EASY").toUpperCase()
|
||||
const difficultyLevel =
|
||||
difficulty === "MEDIUM" || difficulty === "HARD" ? difficulty : "EASY"
|
||||
|
||||
return {
|
||||
id: q.id != null ? `existing-${q.id}` : `q-${q.display_order}`,
|
||||
serverQuestionId: q.id ?? null,
|
||||
displayOrder: q.display_order,
|
||||
questionTypeDefinitionId: defId,
|
||||
text,
|
||||
difficultyLevel,
|
||||
points: Number.isFinite(Number(q.points)) && Number(q.points) > 0
|
||||
? Number(q.points)
|
||||
: 1,
|
||||
dynamicFieldValues,
|
||||
mcqOptions,
|
||||
trueFalseCorrect,
|
||||
shortAnswers,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapPracticeFullToFormState(
|
||||
data: PracticeFullData,
|
||||
typeDefinitions: QuestionTypeDefinition[],
|
||||
): {
|
||||
formData: PracticeFormState
|
||||
personaId: number | null
|
||||
preservedQuestionSet: PreservedQuestionSetFields
|
||||
} {
|
||||
const { practice, question_set, questions } = data
|
||||
const sorted = [...questions].sort(
|
||||
(a, b) => (a.display_order ?? 0) - (b.display_order ?? 0),
|
||||
)
|
||||
|
||||
const formQuestions =
|
||||
sorted.length > 0
|
||||
? sorted.map((q) => mapFullQuestionToFormRow(q, typeDefinitions))
|
||||
: [
|
||||
{
|
||||
id: "q1",
|
||||
displayOrder: 1,
|
||||
serverQuestionId: null,
|
||||
questionTypeDefinitionId: typeDefinitions[0]?.id ?? null,
|
||||
text: "",
|
||||
difficultyLevel: "EASY" as const,
|
||||
points: 1,
|
||||
dynamicFieldValues: typeDefinitions[0]
|
||||
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
|
||||
: {},
|
||||
mcqOptions: defaultMcqOptions(),
|
||||
trueFalseCorrect: true,
|
||||
shortAnswers: [""],
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
formData: {
|
||||
title: practice.title?.trim() || question_set.title?.trim() || "",
|
||||
description:
|
||||
practice.story_description?.trim() ||
|
||||
question_set.description?.trim() ||
|
||||
"",
|
||||
storyImageUrl: practice.story_image?.trim() || "",
|
||||
shuffleQuestions: Boolean(question_set.shuffle_questions),
|
||||
tips: practice.quick_tips?.trim() || "",
|
||||
questions: formQuestions,
|
||||
},
|
||||
personaId:
|
||||
practice.persona_id != null && Number.isFinite(practice.persona_id)
|
||||
? practice.persona_id
|
||||
: null,
|
||||
preservedQuestionSet: {
|
||||
timeLimitMinutes: question_set.time_limit_minutes ?? null,
|
||||
passingScore: question_set.passing_score ?? null,
|
||||
introVideoUrl: question_set.intro_video_url?.trim() || "",
|
||||
status:
|
||||
(question_set.status as PracticePublishStatus) === "DRAFT"
|
||||
? "DRAFT"
|
||||
: "PUBLISHED",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface PracticeEditQuestionInput extends LearnEnglishDefinitionQuestionInput {
|
||||
serverQuestionId?: number | null
|
||||
}
|
||||
|
||||
function buildFullUpdateQuestion(
|
||||
def: QuestionTypeDefinition,
|
||||
q: PracticeEditQuestionInput,
|
||||
status: PracticePublishStatus,
|
||||
displayOrder: number,
|
||||
): PracticeFullQuestionItem {
|
||||
const created = buildCreateQuestionFromDefinition(def, q, status)
|
||||
const item: PracticeFullQuestionItem = {
|
||||
display_order: displayOrder,
|
||||
question_type: created.question_type,
|
||||
difficulty_level: created.difficulty_level,
|
||||
points: created.points,
|
||||
status,
|
||||
}
|
||||
if (q.serverQuestionId != null && q.serverQuestionId > 0) {
|
||||
item.id = q.serverQuestionId
|
||||
}
|
||||
if (created.question_text) item.question_text = created.question_text
|
||||
if (created.question_type_definition_id != null) {
|
||||
item.question_type_definition_id = created.question_type_definition_id
|
||||
}
|
||||
if (created.dynamic_payload) item.dynamic_payload = created.dynamic_payload
|
||||
if (created.options?.length) item.options = created.options
|
||||
if (created.short_answers?.length) {
|
||||
item.short_answers = created.short_answers.map((entry) =>
|
||||
typeof entry === "string"
|
||||
? { acceptable_answer: entry, match_type: "CASE_INSENSITIVE" }
|
||||
: entry,
|
||||
)
|
||||
}
|
||||
if (created.voice_prompt) item.voice_prompt = created.voice_prompt
|
||||
if (created.sample_answer_voice_prompt) {
|
||||
item.sample_answer_voice_prompt = created.sample_answer_voice_prompt
|
||||
}
|
||||
if (created.audio_correct_answer_text) {
|
||||
item.audio_correct_answer_text = created.audio_correct_answer_text
|
||||
}
|
||||
if (created.image_url) item.image_url = created.image_url
|
||||
if (created.tips) item.tips = created.tips
|
||||
if (created.explanation) item.explanation = created.explanation
|
||||
return item
|
||||
}
|
||||
|
||||
export interface BuildPracticeFullUpdateInput {
|
||||
formData: PracticeFormState
|
||||
personaId: number
|
||||
status: PracticePublishStatus
|
||||
preservedQuestionSet: PreservedQuestionSetFields
|
||||
questions: PracticeEditQuestionInput[]
|
||||
definitions: QuestionTypeDefinition[]
|
||||
isLearnEnglishLessonPractice: boolean
|
||||
lessonDefaultTitle?: string
|
||||
}
|
||||
|
||||
export function buildPracticeFullUpdateRequest(
|
||||
opts: BuildPracticeFullUpdateInput,
|
||||
): UpdatePracticeFullRequest {
|
||||
const err = validatePracticeQuestionsWithDefinitions(
|
||||
opts.questions,
|
||||
opts.definitions,
|
||||
)
|
||||
if (err) throw new Error(err)
|
||||
|
||||
const lessonTitle = opts.lessonDefaultTitle?.trim() || "Lesson practice"
|
||||
const practiceTitle = opts.isLearnEnglishLessonPractice
|
||||
? lessonTitle
|
||||
: opts.formData.title.trim() || "Untitled practice"
|
||||
const storyDescription = opts.isLearnEnglishLessonPractice
|
||||
? ""
|
||||
: opts.formData.description.trim()
|
||||
const storyImage = opts.isLearnEnglishLessonPractice
|
||||
? ""
|
||||
: opts.formData.storyImageUrl.trim()
|
||||
|
||||
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
|
||||
const toUpdate = opts.questions
|
||||
.map((q, index) => ({
|
||||
q,
|
||||
sortOrder:
|
||||
Number.isFinite(q.displayOrder) && (q.displayOrder ?? 0) > 0
|
||||
? Number(q.displayOrder)
|
||||
: index + 1,
|
||||
}))
|
||||
.filter(({ q }) => {
|
||||
const def = byId.get(q.questionTypeDefinitionId)
|
||||
return def ? questionRowHasContent(q, def) : false
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
let displayOrder = 0
|
||||
const questions: PracticeFullQuestionItem[] = []
|
||||
for (const { q } of toUpdate) {
|
||||
const def = byId.get(q.questionTypeDefinitionId)
|
||||
if (!def) continue
|
||||
displayOrder += 1
|
||||
questions.push(
|
||||
buildFullUpdateQuestion(def, q, opts.status, displayOrder),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
practice: {
|
||||
title: practiceTitle,
|
||||
story_description: storyDescription,
|
||||
story_image: storyImage,
|
||||
persona_id: opts.personaId,
|
||||
quick_tips: opts.formData.tips.trim(),
|
||||
publish_status: opts.status,
|
||||
},
|
||||
question_set: {
|
||||
title: opts.isLearnEnglishLessonPractice
|
||||
? lessonTitle
|
||||
: opts.formData.title.trim() || "Practice set",
|
||||
description: opts.isLearnEnglishLessonPractice
|
||||
? null
|
||||
: opts.formData.description.trim() || null,
|
||||
time_limit_minutes: opts.preservedQuestionSet.timeLimitMinutes,
|
||||
passing_score: opts.preservedQuestionSet.passingScore,
|
||||
shuffle_questions: opts.formData.shuffleQuestions,
|
||||
status: opts.status,
|
||||
intro_video_url: opts.preservedQuestionSet.introVideoUrl.trim() || null,
|
||||
},
|
||||
questions,
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapPracticeFullData(
|
||||
res: { data?: GetPracticeFullResponse & { Data?: GetPracticeFullResponse["data"] } },
|
||||
): PracticeFullData | null {
|
||||
const body = res.data
|
||||
if (!body) return null
|
||||
const raw = body.data ?? body.Data ?? null
|
||||
if (!raw) return null
|
||||
return normalizePracticeFullData(raw) ?? (raw as PracticeFullData)
|
||||
}
|
||||
|
|
@ -196,6 +196,7 @@ export function AddPracticeFlow() {
|
|||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
displayOrder: 1,
|
||||
questionTypeDefinitionId: null as number | null,
|
||||
text: "",
|
||||
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
||||
|
|
@ -291,7 +292,7 @@ export function AddPracticeFlow() {
|
|||
return;
|
||||
}
|
||||
const persona = personaFromId(selectedPersona, personas);
|
||||
const mappedQuestions = formData.questions.map((q) => ({
|
||||
const mappedQuestions = formData.questions.map((q, index) => ({
|
||||
questionText: String(q.text ?? "").trim(),
|
||||
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
||||
difficultyLevel: (q.difficultyLevel ?? "EASY") as
|
||||
|
|
@ -299,6 +300,10 @@ export function AddPracticeFlow() {
|
|||
| "MEDIUM"
|
||||
| "HARD",
|
||||
points: Number.isFinite(Number(q.points)) ? Number(q.points) : 1,
|
||||
displayOrder:
|
||||
Number.isFinite(Number(q.displayOrder)) && Number(q.displayOrder) > 0
|
||||
? Number(q.displayOrder)
|
||||
: index + 1,
|
||||
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
||||
mcqOptions: (q.mcqOptions ?? []).map(
|
||||
(o: { text?: string; isCorrect?: boolean }) => ({
|
||||
|
|
@ -413,6 +418,7 @@ export function AddPracticeFlow() {
|
|||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
displayOrder: 1,
|
||||
questionTypeDefinitionId:
|
||||
typeDefinitions[0]?.id ?? (null as number | null),
|
||||
text: "",
|
||||
|
|
@ -573,7 +579,7 @@ export function AddPracticeFlow() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
|
||||
<div className="space-y-8 px-6 pb-16 pt-6">
|
||||
<div className="mx-auto max-w-7xl w-full">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -867,7 +867,9 @@ export function CourseDetailPage() {
|
|||
practice={practice}
|
||||
statusUpdating={publishStatusPracticeId === practice.id}
|
||||
onEdit={() =>
|
||||
navigate(`/content/practices?type=course&id=${courseIdNum}`)
|
||||
navigate(
|
||||
`/new-content/learn-english/${programIdParam}/courses/${courseIdNum}/edit-practice/${practice.id}?backTo=courses`,
|
||||
)
|
||||
}
|
||||
onPublish={() =>
|
||||
void handlePracticePublishStatus(
|
||||
|
|
|
|||
646
src/pages/content-management/EditPracticeFlow.tsx
Normal file
646
src/pages/content-management/EditPracticeFlow.tsx
Normal file
|
|
@ -0,0 +1,646 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Stepper } from "../../components/ui/stepper";
|
||||
import successIcon from "../../assets/success.svg";
|
||||
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types";
|
||||
import {
|
||||
getExamPrepPracticeFull,
|
||||
getLearnEnglishPracticeFull,
|
||||
} from "../../api/courses.api";
|
||||
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api";
|
||||
import {
|
||||
learnEnglishPracticeApiErrorMessage,
|
||||
validateLearnEnglishQuestionsWithDefinitions,
|
||||
} from "../../lib/learnEnglishPracticePublish";
|
||||
import { executePracticeUpdate } from "../../lib/practiceEditOrchestrator";
|
||||
import {
|
||||
mapPracticeFullToFormState,
|
||||
unwrapPracticeFullData,
|
||||
type PreservedQuestionSetFields,
|
||||
} from "../../lib/practiceFullMapper";
|
||||
|
||||
import { ContextStep } from "./components/practice-steps/ContextStep";
|
||||
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
||||
import { PersonaStep } from "./components/practice-steps/PersonaStep";
|
||||
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
|
||||
import { ReviewStep } from "./components/practice-steps/ReviewStep";
|
||||
import { personaIdNumber } from "./components/practice-steps/constants";
|
||||
import { useActivePersonas } from "../../hooks/useActivePersonas";
|
||||
|
||||
const STEP_LABELS = ["Practice", "Persona", "Questions", "Review"] as const;
|
||||
|
||||
export function EditPracticeFlow() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
level,
|
||||
programType,
|
||||
courseId: routeCourseId,
|
||||
unitId: routeUnitId,
|
||||
moduleId: routeModuleId,
|
||||
lessonId: routeLessonId,
|
||||
practiceId: routePracticeId,
|
||||
} = useParams<{
|
||||
level?: string;
|
||||
programType?: string;
|
||||
courseId?: string;
|
||||
unitId?: string;
|
||||
moduleId?: string;
|
||||
lessonId?: string;
|
||||
practiceId?: string;
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const backToParam = searchParams.get("backTo");
|
||||
const lessonTitleRaw = searchParams.get("lessonTitle");
|
||||
|
||||
const practiceId = routePracticeId ? Number(routePracticeId) : NaN;
|
||||
const validPracticeId = Number.isFinite(practiceId) && practiceId > 0;
|
||||
|
||||
const isExamPrep = Boolean(programType?.trim());
|
||||
const lessonId = routeLessonId ?? searchParams.get("lessonId");
|
||||
|
||||
const effectiveBackTo = useMemo(() => {
|
||||
if (backToParam?.trim()) return backToParam.trim();
|
||||
if (routeLessonId) return "lesson";
|
||||
if (isExamPrep && routeModuleId) return "module";
|
||||
if (isExamPrep && routeCourseId) return "courses";
|
||||
if (routeModuleId) return "module";
|
||||
if (routeCourseId) return "courses";
|
||||
return null;
|
||||
}, [
|
||||
backToParam,
|
||||
routeLessonId,
|
||||
isExamPrep,
|
||||
routeModuleId,
|
||||
routeCourseId,
|
||||
]);
|
||||
|
||||
const courseId = isExamPrep
|
||||
? routeCourseId ?? searchParams.get("courseId")
|
||||
: routeCourseId ?? searchParams.get("courseId");
|
||||
const moduleId = isExamPrep
|
||||
? routeModuleId ?? searchParams.get("moduleId")
|
||||
: routeModuleId ?? searchParams.get("moduleId");
|
||||
const unitId = isExamPrep ? routeUnitId : null;
|
||||
|
||||
const lessonTitleDisplay = (() => {
|
||||
const raw = lessonTitleRaw?.trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
})();
|
||||
|
||||
const isModuleContext = effectiveBackTo === "module";
|
||||
const isCourseContext =
|
||||
effectiveBackTo === "courses" || effectiveBackTo === "modules";
|
||||
const isLessonContext = effectiveBackTo === "lesson" || Boolean(routeLessonId);
|
||||
const isLessonPractice = useMemo(() => {
|
||||
const lid = lessonId ? Number(lessonId) : NaN;
|
||||
return Number.isFinite(lid) && lid > 0;
|
||||
}, [lessonId]);
|
||||
const isLearnEnglishLessonPractice = isLessonPractice && !isExamPrep;
|
||||
|
||||
const parentSummary = useMemo(() => {
|
||||
if (lessonId)
|
||||
return `Lesson #${lessonId}${lessonTitleDisplay ? ` — ${lessonTitleDisplay}` : ""}`;
|
||||
if (isModuleContext && moduleId) return `Module #${moduleId}`;
|
||||
if (isCourseContext && courseId) return `Course #${courseId}`;
|
||||
return null;
|
||||
}, [
|
||||
lessonId,
|
||||
lessonTitleDisplay,
|
||||
isModuleContext,
|
||||
isCourseContext,
|
||||
moduleId,
|
||||
courseId,
|
||||
]);
|
||||
|
||||
const programLabel = isExamPrep
|
||||
? programType === "skill"
|
||||
? "Skill-Based Courses"
|
||||
: "English Proficiency Exams"
|
||||
: level
|
||||
? `Program ${level}`
|
||||
: null;
|
||||
|
||||
const backLabel =
|
||||
effectiveBackTo === "lesson"
|
||||
? "Back to lesson practices"
|
||||
: effectiveBackTo === "module"
|
||||
? "Back to Module"
|
||||
: effectiveBackTo === "modules"
|
||||
? "Back to Modules"
|
||||
: effectiveBackTo === "courses"
|
||||
? "Back to Course"
|
||||
: isExamPrep
|
||||
? "Back to Program"
|
||||
: "Back to Courses";
|
||||
|
||||
const backPath = useMemo(() => {
|
||||
if (isExamPrep) {
|
||||
if (routeLessonId && programType && courseId && unitId && moduleId) {
|
||||
const title = lessonTitleRaw
|
||||
? `?lessonTitle=${encodeURIComponent(lessonTitleRaw)}`
|
||||
: "";
|
||||
return `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/lessons/${routeLessonId}/practices${title}`;
|
||||
}
|
||||
if (
|
||||
effectiveBackTo === "module" &&
|
||||
programType &&
|
||||
courseId &&
|
||||
unitId &&
|
||||
moduleId
|
||||
) {
|
||||
return `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`;
|
||||
}
|
||||
if (effectiveBackTo === "courses" && programType && courseId) {
|
||||
return `/new-content/courses/${programType}/${courseId}`;
|
||||
}
|
||||
if (programType) return `/new-content/courses/${programType}`;
|
||||
return "/new-content";
|
||||
}
|
||||
if (routeLessonId && level && courseId && moduleId) {
|
||||
const title = lessonTitleRaw
|
||||
? `?lessonTitle=${encodeURIComponent(lessonTitleRaw)}`
|
||||
: "";
|
||||
return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${routeLessonId}/practices${title}`;
|
||||
}
|
||||
if (effectiveBackTo === "module" && level && courseId && moduleId) {
|
||||
return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
||||
}
|
||||
if (effectiveBackTo === "courses" && level && courseId) {
|
||||
return `/new-content/learn-english/${level}/courses/${courseId}`;
|
||||
}
|
||||
if (level) return `/new-content/learn-english/${level}/courses`;
|
||||
return "/new-content";
|
||||
}, [
|
||||
isExamPrep,
|
||||
routeLessonId,
|
||||
programType,
|
||||
courseId,
|
||||
unitId,
|
||||
moduleId,
|
||||
lessonTitleRaw,
|
||||
effectiveBackTo,
|
||||
level,
|
||||
]);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loadingPractice, setLoadingPractice] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
|
||||
const [preservedQuestionSet, setPreservedQuestionSet] =
|
||||
useState<PreservedQuestionSetFields>({
|
||||
timeLimitMinutes: null,
|
||||
passingScore: null,
|
||||
introVideoUrl: "",
|
||||
status: "PUBLISHED",
|
||||
});
|
||||
|
||||
const {
|
||||
personas,
|
||||
loading: personasLoading,
|
||||
error: personasError,
|
||||
reload: reloadPersonas,
|
||||
} = useActivePersonas();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
storyImageUrl: "",
|
||||
shuffleQuestions: false,
|
||||
tips: "",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
displayOrder: 1,
|
||||
serverQuestionId: null as number | null,
|
||||
questionTypeDefinitionId: null as number | null,
|
||||
text: "",
|
||||
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
||||
points: 1,
|
||||
dynamicFieldValues: {} as Record<string, string>,
|
||||
mcqOptions: [
|
||||
{ text: "", isCorrect: true },
|
||||
{ text: "", isCorrect: false },
|
||||
{ text: "", isCorrect: false },
|
||||
{ text: "", isCorrect: false },
|
||||
],
|
||||
trueFalseCorrect: true,
|
||||
shortAnswers: [""],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>(
|
||||
[],
|
||||
);
|
||||
const [definitionsLoading, setDefinitionsLoading] = useState(true);
|
||||
const [definitionsError, setDefinitionsError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setDefinitionsLoading(true);
|
||||
setDefinitionsError(null);
|
||||
try {
|
||||
const { definitions: list } = await getQuestionTypeDefinitions({
|
||||
include_system: true,
|
||||
status: "ACTIVE",
|
||||
});
|
||||
if (!cancelled) setTypeDefinitions(list);
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setDefinitionsError(learnEnglishPracticeApiErrorMessage(e));
|
||||
setTypeDefinitions([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setDefinitionsLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validPracticeId || definitionsLoading) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoadingPractice(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = isExamPrep
|
||||
? await getExamPrepPracticeFull(practiceId)
|
||||
: await getLearnEnglishPracticeFull(practiceId);
|
||||
const full = unwrapPracticeFullData(res);
|
||||
if (!full) throw new Error("Practice details were missing from the response.");
|
||||
const mapped = mapPracticeFullToFormState(full, typeDefinitions);
|
||||
if (cancelled) return;
|
||||
setFormData(mapped.formData);
|
||||
setPreservedQuestionSet(mapped.preservedQuestionSet);
|
||||
if (mapped.personaId != null) {
|
||||
setSelectedPersona(String(mapped.personaId));
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setLoadError(learnEnglishPracticeApiErrorMessage(e));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingPractice(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [validPracticeId, practiceId, isExamPrep, typeDefinitions, definitionsLoading]);
|
||||
|
||||
const submitPractice = async (status: "DRAFT" | "PUBLISHED") => {
|
||||
if (!validPracticeId) {
|
||||
toast.error("Invalid practice", { description: "Missing practice id in the URL." });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isLearnEnglishLessonPractice &&
|
||||
(!formData.title.trim() || !formData.description.trim())
|
||||
) {
|
||||
toast.error("Title and story description are required", {
|
||||
description: "Complete the first step before saving.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!selectedPersona) {
|
||||
toast.error("Select a persona", {
|
||||
description: "Choose a character on the Persona step before saving.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const personaId = personaIdNumber(selectedPersona);
|
||||
if (!personaId) {
|
||||
toast.error("Invalid persona", {
|
||||
description: "Re-select a persona from the list and try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedQuestions = formData.questions.map((q, index) => ({
|
||||
questionText: String(q.text ?? "").trim(),
|
||||
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
||||
difficultyLevel: (q.difficultyLevel ?? "EASY") as "EASY" | "MEDIUM" | "HARD",
|
||||
points: Number.isFinite(Number(q.points)) ? Number(q.points) : 1,
|
||||
displayOrder:
|
||||
Number.isFinite(Number(q.displayOrder)) && Number(q.displayOrder) > 0
|
||||
? Number(q.displayOrder)
|
||||
: index + 1,
|
||||
serverQuestionId: q.serverQuestionId ?? null,
|
||||
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
||||
mcqOptions: (q.mcqOptions ?? []).map(
|
||||
(o: { text?: string; isCorrect?: boolean }) => ({
|
||||
option_text: String(o.text ?? ""),
|
||||
is_correct: Boolean(o.isCorrect),
|
||||
}),
|
||||
),
|
||||
trueFalseAnswerIsTrue: q.trueFalseCorrect !== false,
|
||||
shortAnswers: (q.shortAnswers ?? []).map((s: string) => String(s)),
|
||||
}));
|
||||
|
||||
const validationMsg = validateLearnEnglishQuestionsWithDefinitions(
|
||||
mappedQuestions,
|
||||
typeDefinitions,
|
||||
);
|
||||
if (validationMsg) {
|
||||
toast.error("Check your questions", { description: validationMsg });
|
||||
return;
|
||||
}
|
||||
|
||||
const lessonDefaultTitle =
|
||||
lessonTitleDisplay?.trim() ||
|
||||
(lessonId ? `Lesson ${lessonId} practice` : "Lesson practice");
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await executePracticeUpdate({
|
||||
practiceId,
|
||||
isExamPrep,
|
||||
status,
|
||||
formData,
|
||||
personaId,
|
||||
preservedQuestionSet: {
|
||||
...preservedQuestionSet,
|
||||
status,
|
||||
},
|
||||
questions: mappedQuestions,
|
||||
definitions: typeDefinitions,
|
||||
isLearnEnglishLessonPractice,
|
||||
lessonDefaultTitle,
|
||||
});
|
||||
toast.success(
|
||||
status === "PUBLISHED" ? "Practice updated and published" : "Practice saved as draft",
|
||||
);
|
||||
setIsSaved(true);
|
||||
} catch (e) {
|
||||
toast.error("Could not update practice", {
|
||||
description: learnEnglishPracticeApiErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = () =>
|
||||
setCurrentStep((prev) => Math.min(prev + 1, STEP_LABELS.length));
|
||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
|
||||
if (!validPracticeId) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<p className="text-lg font-semibold text-grayScale-800">Invalid practice link</p>
|
||||
<Button className="mt-6" variant="outline" onClick={() => navigate(-1)}>
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadingPractice || definitionsLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-brand-500" />
|
||||
<p className="text-sm font-medium text-grayScale-600">Loading practice details…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<p className="text-lg font-semibold text-grayScale-800">Could not load practice</p>
|
||||
<p className="mt-2 max-w-md text-sm text-grayScale-600">{loadError}</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button variant="outline" onClick={() => navigate(backPath)}>
|
||||
{backLabel}
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()}>Try again</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSaved) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500">
|
||||
<div className="mb-10 relative">
|
||||
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
|
||||
<img
|
||||
src={successIcon}
|
||||
alt="Success"
|
||||
className="h-[128px] w-[128px] relative"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
|
||||
Practice Updated Successfully!
|
||||
</h1>
|
||||
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
|
||||
Your changes to this practice have been saved.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate(backPath)}
|
||||
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white w-full max-w-[400px]"
|
||||
>
|
||||
{backLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderStep = () => {
|
||||
const useContextStep =
|
||||
isModuleContext || isCourseContext || isLessonContext;
|
||||
|
||||
if (useContextStep) {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ContextStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
onCancel={() => navigate(backPath)}
|
||||
isLessonPractice={isLearnEnglishLessonPractice}
|
||||
lessonTitle={lessonTitleDisplay}
|
||||
parentSummary={parentSummary}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<PersonaStep
|
||||
personas={personas}
|
||||
loading={personasLoading}
|
||||
error={personasError}
|
||||
onRetry={() => void reloadPersonas()}
|
||||
selectedPersona={selectedPersona}
|
||||
setSelectedPersona={setSelectedPersona}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<QuestionsStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
typeDefinitions={typeDefinitions}
|
||||
definitionsLoading={definitionsLoading}
|
||||
definitionsError={definitionsError}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<ReviewStep
|
||||
formData={formData}
|
||||
selectedPersona={selectedPersona}
|
||||
personas={personas}
|
||||
isLessonPractice={isLearnEnglishLessonPractice}
|
||||
lessonTitle={lessonTitleDisplay}
|
||||
programLabel={programLabel}
|
||||
courseLabel={courseId ? `Course ${courseId}` : null}
|
||||
moduleLabel={moduleId ? `Module ${moduleId}` : null}
|
||||
prevStep={prevStep}
|
||||
onEditContext={() => setCurrentStep(1)}
|
||||
onEditQuestions={() => setCurrentStep(3)}
|
||||
parentSummary={parentSummary}
|
||||
typeDefinitions={typeDefinitions}
|
||||
canPublish
|
||||
submitting={submitting}
|
||||
onSaveDraft={() => void submitPractice("DRAFT")}
|
||||
onPublish={() => void submitPractice("PUBLISHED")}
|
||||
publishLabel="Publish Updates"
|
||||
publishingLabel="Publishing updates…"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ScenarioStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
cancelHref={backPath}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<PersonaStep
|
||||
personas={personas}
|
||||
loading={personasLoading}
|
||||
error={personasError}
|
||||
onRetry={() => void reloadPersonas()}
|
||||
selectedPersona={selectedPersona}
|
||||
setSelectedPersona={setSelectedPersona}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<QuestionsStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
typeDefinitions={typeDefinitions}
|
||||
definitionsLoading={definitionsLoading}
|
||||
definitionsError={definitionsError}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<ReviewStep
|
||||
formData={formData}
|
||||
selectedPersona={selectedPersona}
|
||||
personas={personas}
|
||||
isLessonPractice={isLearnEnglishLessonPractice}
|
||||
lessonTitle={lessonTitleDisplay}
|
||||
programLabel={programLabel}
|
||||
courseLabel={courseId ? `Course ${courseId}` : null}
|
||||
moduleLabel={moduleId ? `Module ${moduleId}` : null}
|
||||
prevStep={prevStep}
|
||||
onEditContext={() => setCurrentStep(1)}
|
||||
onEditQuestions={() => setCurrentStep(3)}
|
||||
parentSummary={parentSummary}
|
||||
typeDefinitions={typeDefinitions}
|
||||
canPublish
|
||||
submitting={submitting}
|
||||
onSaveDraft={() => void submitPractice("DRAFT")}
|
||||
onPublish={() => void submitPractice("PUBLISHED")}
|
||||
publishLabel="Publish Updates"
|
||||
publishingLabel="Publishing updates…"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 px-6 pb-16 pt-6">
|
||||
<div className="mx-auto max-w-7xl w-full">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<Link
|
||||
to={backPath}
|
||||
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500 decoration-none"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{backLabel}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-[#0F172A]">Edit Practice</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-[8px] border-grayScale-200 text-grayScale-600 h-10 px-6 font-bold bg-white hover:bg-grayScale-50"
|
||||
onClick={() => navigate(backPath)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-grayScale-400 text-base">
|
||||
Update story details, persona, and questions for practice #{practiceId}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-[70%] mb-12">
|
||||
<Stepper steps={[...STEP_LABELS]} currentStep={currentStep} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mx-auto ${currentStep === 3 || currentStep === 4 ? "max-w-6xl" : "max-w-4xl"}`}
|
||||
>
|
||||
{renderStep()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
BookOpen,
|
||||
Calendar,
|
||||
Clock,
|
||||
Edit2,
|
||||
Hash,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
|
|
@ -91,6 +92,7 @@ function PracticeCard({
|
|||
practice,
|
||||
index,
|
||||
total,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTogglePublishStatus,
|
||||
publishStatusUpdating,
|
||||
|
|
@ -98,6 +100,7 @@ function PracticeCard({
|
|||
practice: ParentContextPractice;
|
||||
index: number;
|
||||
total: number;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
|
||||
publishStatusUpdating?: boolean;
|
||||
|
|
@ -201,7 +204,7 @@ function PracticeCard({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2 border-t border-grayScale-100 pt-5">
|
||||
<div className="mt-6 flex flex-wrap items-center gap-2 border-t border-grayScale-100 pt-5">
|
||||
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
|
||||
<Hash className="h-3 w-3 opacity-70" aria-hidden />
|
||||
Question set {practice.question_set_id}
|
||||
|
|
@ -210,6 +213,18 @@ function PracticeCard({
|
|||
<Clock className="h-3 w-3 opacity-70" aria-hidden />
|
||||
{formatPracticeDate(practice.created_at)}
|
||||
</Badge>
|
||||
{onEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto h-9 rounded-[10px] border-brand-500 text-xs font-bold text-brand-500 hover:bg-brand-50"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -321,6 +336,16 @@ export function LessonPracticesPage() {
|
|||
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
|
||||
: `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
|
||||
|
||||
const editPracticeHref = (practiceId: number) => {
|
||||
const titleQuery = lessonTitle
|
||||
? `lessonTitle=${encodeURIComponent(lessonTitle)}&`
|
||||
: "";
|
||||
if (isExamPrep) {
|
||||
return `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/lessons/${lid}/edit-practice/${practiceId}?${titleQuery}backTo=lesson`;
|
||||
}
|
||||
return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${lid}/edit-practice/${practiceId}?${titleQuery}backTo=lesson`;
|
||||
};
|
||||
|
||||
const handlePracticePublishStatus = async (
|
||||
practiceId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
|
|
@ -547,6 +572,7 @@ export function LessonPracticesPage() {
|
|||
practice={p}
|
||||
index={i}
|
||||
total={filteredPractices.length}
|
||||
onEdit={() => void navigate(editPracticeHref(p.id))}
|
||||
onDelete={
|
||||
isExamPrep ? () => setPracticeToDelete(p) : undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -657,7 +657,7 @@ export function ModuleDetailPage() {
|
|||
statusUpdating={publishStatusPracticeId === practice.id}
|
||||
onEdit={() =>
|
||||
navigate(
|
||||
`/content/practices?type=module&id=${moduleId}`,
|
||||
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/edit-practice/${practice.id}?backTo=module`,
|
||||
)
|
||||
}
|
||||
onPublish={() =>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export type PracticeSequentialReviewProps = {
|
|||
onBack: () => void;
|
||||
onSaveDraft: () => void;
|
||||
onPublish: () => void;
|
||||
publishLabel?: string;
|
||||
publishingLabel?: string;
|
||||
sectionTitle?: string;
|
||||
sectionSubtitle?: string;
|
||||
};
|
||||
|
|
@ -136,6 +138,8 @@ export function PracticeSequentialReview({
|
|||
onBack,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
publishLabel = "Publish Now",
|
||||
publishingLabel = "Publishing…",
|
||||
sectionTitle = "Create Practice Questions",
|
||||
sectionSubtitle = "Define the dialogue flow and interactions for this scenario.",
|
||||
}: PracticeSequentialReviewProps) {
|
||||
|
|
@ -280,7 +284,7 @@ export function PracticeSequentialReview({
|
|||
{filledQuestions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto px-6 py-5">
|
||||
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto overscroll-y-contain px-6 py-5">
|
||||
{filledQuestions.map((question, index) => (
|
||||
<div key={question.id} className="space-y-3">
|
||||
<span className="text-sm font-bold text-grayScale-400">
|
||||
|
|
@ -329,7 +333,7 @@ export function PracticeSequentialReview({
|
|||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto px-6 py-5">
|
||||
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto overscroll-y-contain px-6 py-5">
|
||||
{filledQuestions.map((question, index) => (
|
||||
<div key={question.id} className="space-y-3">
|
||||
<span className="text-sm font-bold text-grayScale-400">
|
||||
|
|
@ -398,7 +402,7 @@ export function PracticeSequentialReview({
|
|||
) : (
|
||||
<Rocket className="h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Publishing…" : "Publish Now"}
|
||||
{saving ? publishingLabel : publishLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,31 @@
|
|||
import { Trash2, Plus, ArrowRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Trash2,
|
||||
Plus,
|
||||
ArrowRight,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
|
|
@ -11,8 +38,111 @@ import {
|
|||
legacyQuestionTypeFromDefinition,
|
||||
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
||||
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function syncQuestionDisplayOrders<T extends { displayOrder?: number }>(
|
||||
questions: T[],
|
||||
): T[] {
|
||||
return questions.map((q, index) => ({ ...q, displayOrder: index + 1 }));
|
||||
}
|
||||
|
||||
function truncateText(value: string, max = 100): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
|
||||
}
|
||||
|
||||
function questionSummaryPreview(
|
||||
q: {
|
||||
text?: string;
|
||||
dynamicFieldValues?: Record<string, string>;
|
||||
questionTypeDefinitionId?: number | null;
|
||||
difficultyLevel?: string;
|
||||
points?: number;
|
||||
},
|
||||
def: QuestionTypeDefinition | undefined,
|
||||
): string {
|
||||
const text = String(q.text ?? "").trim();
|
||||
if (text) return truncateText(text);
|
||||
|
||||
const values = Object.values(q.dynamicFieldValues ?? {})
|
||||
.map((v) => String(v ?? "").trim())
|
||||
.filter((v) => v && !v.startsWith("{") && !v.startsWith("["));
|
||||
if (values[0]) return truncateText(values[0]);
|
||||
|
||||
if (def) return questionTypeDefinitionListLabel(def);
|
||||
return "No content yet";
|
||||
}
|
||||
|
||||
function QuestionCollapsibleBody({
|
||||
expanded,
|
||||
children,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-in-out",
|
||||
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"border-t border-grayScale-50 transition-opacity duration-300",
|
||||
expanded ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableQuestionCardProps {
|
||||
id: string;
|
||||
children: (opts: {
|
||||
dragHandleProps: React.HTMLAttributes<HTMLButtonElement>;
|
||||
isDragging: boolean;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
function SortableQuestionCard({ id, children }: SortableQuestionCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(isDragging && "relative z-50 opacity-60")}
|
||||
>
|
||||
{children({
|
||||
isDragging,
|
||||
dragHandleProps: {
|
||||
...attributes,
|
||||
...listeners,
|
||||
type: "button",
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultMcqOptions() {
|
||||
return [
|
||||
{ text: "", isCorrect: true },
|
||||
|
|
@ -22,9 +152,11 @@ function defaultMcqOptions() {
|
|||
];
|
||||
}
|
||||
|
||||
function createEmptyQuestionRow(id: string) {
|
||||
function createEmptyQuestionRow(id: string, displayOrder = 1) {
|
||||
return {
|
||||
id,
|
||||
displayOrder,
|
||||
serverQuestionId: null as number | null,
|
||||
questionTypeDefinitionId: null as number | null,
|
||||
text: "",
|
||||
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
||||
|
|
@ -55,6 +187,65 @@ export function QuestionsStep({
|
|||
definitionsLoading,
|
||||
definitionsError,
|
||||
}: QuestionsStepProps) {
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
const [expandedQuestionIds, setExpandedQuestionIds] = useState<Set<string>>(
|
||||
() => new Set(formData.questions[0]?.id ? [formData.questions[0].id] : []),
|
||||
);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
const questionIds = formData.questions.map((q: { id: string }) => q.id);
|
||||
const canReorder = formData.questions.length > 1;
|
||||
const activeDragIndex = activeDragId
|
||||
? formData.questions.findIndex((q: { id: string }) => q.id === activeDragId)
|
||||
: -1;
|
||||
|
||||
const reorderQuestions = (activeId: string, overId: string) => {
|
||||
const oldIndex = formData.questions.findIndex(
|
||||
(q: { id: string }) => q.id === activeId,
|
||||
);
|
||||
const newIndex = formData.questions.findIndex(
|
||||
(q: { id: string }) => q.id === overId,
|
||||
);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
setFormData({
|
||||
...formData,
|
||||
questions: syncQuestionDisplayOrders(
|
||||
arrayMove(formData.questions, oldIndex, newIndex),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleQuestionExpanded = (id: string) => {
|
||||
setExpandedQuestionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const collapseAllQuestions = () => setExpandedQuestionIds(new Set());
|
||||
|
||||
const expandAllQuestions = () =>
|
||||
setExpandedQuestionIds(new Set(questionIds));
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveDragId(String(event.active.id));
|
||||
collapseAllQuestions();
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
reorderQuestions(String(active.id), String(over.id));
|
||||
}
|
||||
setActiveDragId(null);
|
||||
};
|
||||
|
||||
const applyDefinitionToQuestion = (
|
||||
index: number,
|
||||
definitionId: number,
|
||||
|
|
@ -93,8 +284,9 @@ export function QuestionsStep({
|
|||
}
|
||||
setFormData({
|
||||
...formData,
|
||||
questions: [...formData.questions, row],
|
||||
questions: syncQuestionDisplayOrders([...formData.questions, row]),
|
||||
});
|
||||
setExpandedQuestionIds(new Set([id]));
|
||||
};
|
||||
|
||||
const renderTypeSpecificFields = (q: any, i: number, def: QuestionTypeDefinition) => {
|
||||
|
|
@ -319,11 +511,36 @@ export function QuestionsStep({
|
|||
<div className="space-y-1 px-2">
|
||||
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
|
||||
<p className="text-grayScale-400 text-lg">
|
||||
Choose a question type for each item, then fill in the fields that type requires. Questions are saved
|
||||
when you publish or save the practice.
|
||||
Choose a question type for each item, then fill in the fields that type requires. Collapse cards to
|
||||
compare and drag them into order. Questions are saved when you publish or save the practice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formData.questions.length > 1 ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 px-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 gap-2 rounded-lg border-grayScale-200 text-grayScale-700"
|
||||
onClick={collapseAllQuestions}
|
||||
>
|
||||
<ChevronsDownUp className="h-4 w-4" />
|
||||
Collapse all
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 gap-2 rounded-lg border-grayScale-200 text-grayScale-700"
|
||||
onClick={expandAllQuestions}
|
||||
>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
Expand all
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{definitionsError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{definitionsError}
|
||||
|
|
@ -334,50 +551,134 @@ export function QuestionsStep({
|
|||
<p className="px-2 text-sm text-grayScale-500">Loading question types…</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-6">
|
||||
{formData.questions.map((q: any, i: number) => {
|
||||
const def = typeDefinitions.find(
|
||||
(d) => d.id === q.questionTypeDefinitionId,
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
key={q.id}
|
||||
className="relative overflow-hidden rounded-2xl border border-grayScale-50 bg-white shadow-soft"
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
|
||||
<div className="space-y-6 px-5 pb-7 pt-4 pl-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-grayScale-50 pb-4">
|
||||
<span className="text-base font-bold text-grayScale-500">
|
||||
Question {i + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="text-brand-500 hover:bg-brand-50 rounded-lg"
|
||||
onClick={() => {
|
||||
const newQuestions = formData.questions.filter(
|
||||
(item: any) => item.id !== q.id,
|
||||
);
|
||||
if (newQuestions.length > 0) {
|
||||
setFormData({ ...formData, questions: newQuestions });
|
||||
return;
|
||||
}
|
||||
const row = createEmptyQuestionRow("q1");
|
||||
if (typeDefinitions[0]) {
|
||||
row.questionTypeDefinitionId = typeDefinitions[0].id;
|
||||
row.dynamicFieldValues =
|
||||
emptyDynamicFieldValuesForDefinition(
|
||||
typeDefinitions[0],
|
||||
);
|
||||
}
|
||||
setFormData({ ...formData, questions: [row] });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={questionIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{formData.questions.map((q: any, i: number) => {
|
||||
const def = typeDefinitions.find(
|
||||
(d) => d.id === q.questionTypeDefinitionId,
|
||||
);
|
||||
const isExpanded = expandedQuestionIds.has(q.id);
|
||||
const summary = questionSummaryPreview(q, def);
|
||||
const typeLabel = def
|
||||
? questionTypeDefinitionListLabel(def)
|
||||
: "No type selected";
|
||||
return (
|
||||
<SortableQuestionCard key={q.id} id={q.id}>
|
||||
{({ dragHandleProps, isDragging }) => (
|
||||
<Card
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-2xl border border-grayScale-50 bg-white shadow-soft transition-shadow duration-300",
|
||||
isDragging && "shadow-lg ring-2 ring-brand-200",
|
||||
!isExpanded && "hover:border-grayScale-200",
|
||||
)}
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
|
||||
<div className="pl-7">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 px-4 py-3 sm:px-5",
|
||||
isExpanded ? "pb-1" : "pb-3",
|
||||
)}
|
||||
>
|
||||
{canReorder ? (
|
||||
<button
|
||||
{...dragHandleProps}
|
||||
className="mt-0.5 shrink-0 cursor-grab touch-none rounded-lg p-1 text-grayScale-400 transition-colors hover:bg-grayScale-50 hover:text-grayScale-600 active:cursor-grabbing"
|
||||
aria-label={`Drag to reorder question ${i + 1}`}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-start gap-3 text-left"
|
||||
onClick={() => toggleQuestionExpanded(q.id)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"mt-0.5 h-5 w-5 shrink-0 text-grayScale-400 transition-transform duration-300",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="text-base font-bold text-grayScale-700">
|
||||
Question {q.displayOrder ?? i + 1}
|
||||
</span>
|
||||
<span className="rounded-full bg-grayScale-100 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-grayScale-600">
|
||||
{q.difficultyLevel ?? "EASY"}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-grayScale-500">
|
||||
{q.points ?? 1} pt{(q.points ?? 1) === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-brand-600">
|
||||
{typeLabel}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm text-grayScale-500 transition-all duration-300",
|
||||
isExpanded
|
||||
? "line-clamp-1 opacity-80"
|
||||
: "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{summary}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="shrink-0 text-brand-500 hover:bg-brand-50 rounded-lg"
|
||||
onClick={() => {
|
||||
const newQuestions = formData.questions.filter(
|
||||
(item: any) => item.id !== q.id,
|
||||
);
|
||||
setExpandedQuestionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(q.id);
|
||||
return next;
|
||||
});
|
||||
if (newQuestions.length > 0) {
|
||||
setFormData({
|
||||
...formData,
|
||||
questions: syncQuestionDisplayOrders(newQuestions),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const row = createEmptyQuestionRow("q1");
|
||||
if (typeDefinitions[0]) {
|
||||
row.questionTypeDefinitionId = typeDefinitions[0].id;
|
||||
row.dynamicFieldValues =
|
||||
emptyDynamicFieldValuesForDefinition(
|
||||
typeDefinitions[0],
|
||||
);
|
||||
}
|
||||
setFormData({
|
||||
...formData,
|
||||
questions: syncQuestionDisplayOrders([row]),
|
||||
});
|
||||
setExpandedQuestionIds(new Set(["q1"]));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<QuestionCollapsibleBody expanded={isExpanded}>
|
||||
<div className="space-y-6 px-4 pb-6 pt-4 sm:px-5 sm:pb-7">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||
|
|
@ -484,25 +785,63 @@ export function QuestionsStep({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{def ? renderTypeSpecificFields(q, i, def) : null}
|
||||
</div>
|
||||
{def ? renderTypeSpecificFields(q, i, def) : null}
|
||||
</div>
|
||||
</QuestionCollapsibleBody>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</SortableQuestionCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeDragId && activeDragIndex >= 0 ? (
|
||||
<Card className="relative w-[min(100vw-2rem,42rem)] overflow-hidden rounded-2xl border border-brand-300 bg-white py-3 pl-7 pr-4 shadow-xl">
|
||||
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
|
||||
{(() => {
|
||||
const dragged = formData.questions[activeDragIndex];
|
||||
const draggedDef = typeDefinitions.find(
|
||||
(d) => d.id === dragged?.questionTypeDefinitionId,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-start gap-2 pl-4">
|
||||
<GripVertical className="mt-0.5 h-5 w-5 shrink-0 text-grayScale-400" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-base font-bold text-grayScale-700">
|
||||
Question{" "}
|
||||
{dragged?.displayOrder ?? activeDragIndex + 1}
|
||||
</p>
|
||||
<p className="text-xs font-medium text-brand-600">
|
||||
{draggedDef
|
||||
? questionTypeDefinitionListLabel(draggedDef)
|
||||
: "No type selected"}
|
||||
</p>
|
||||
<p className="line-clamp-2 text-sm text-grayScale-500">
|
||||
{questionSummaryPreview(dragged, draggedDef)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
<div className="flex items-center gap-8 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addQuestion}
|
||||
disabled={definitionsLoading || typeDefinitions.length === 0}
|
||||
className="flex items-center gap-3 text-base font-bold text-brand-500 transition-all hover:opacity-80 disabled:opacity-40"
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
|
||||
<Plus className="h-3 w-3 stroke-[4]" />
|
||||
</div>
|
||||
Add question
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addQuestion}
|
||||
disabled={definitionsLoading || typeDefinitions.length === 0}
|
||||
className="flex items-center gap-3 text-base font-bold text-brand-500 transition-all hover:opacity-80 disabled:opacity-40"
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
|
||||
<Plus className="h-3 w-3 stroke-[4]" />
|
||||
</div>
|
||||
Add question
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-8">
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ interface ReviewStepProps {
|
|||
submitting: boolean;
|
||||
onSaveDraft: () => void;
|
||||
onPublish: () => void;
|
||||
publishLabel?: string;
|
||||
publishingLabel?: string;
|
||||
}
|
||||
|
||||
export function ReviewStep({
|
||||
|
|
@ -58,6 +60,8 @@ export function ReviewStep({
|
|||
submitting,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
publishLabel,
|
||||
publishingLabel,
|
||||
}: ReviewStepProps) {
|
||||
const persona = personaFromId(selectedPersona, personas);
|
||||
|
||||
|
|
@ -114,6 +118,8 @@ export function ReviewStep({
|
|||
onBack={prevStep}
|
||||
onSaveDraft={onSaveDraft}
|
||||
onPublish={onPublish}
|
||||
publishLabel={publishLabel}
|
||||
publishingLabel={publishingLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,254 @@
|
|||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Bell, Mail, MailOpen, Megaphone } from "lucide-react"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { Bell, CalendarClock, Mail, MailOpen, Megaphone, Search, Smartphone } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { FileUpload } from "../../components/ui/file-upload"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { NotificationSchedulePicker } from "../../components/notifications/NotificationSchedulePicker"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { getTeamMembers } from "../../api/team.api"
|
||||
import type { TeamMember } from "../../types/team.types"
|
||||
import {
|
||||
sendBulkEmail,
|
||||
sendBulkInApp,
|
||||
sendBulkPush,
|
||||
sendBulkSms,
|
||||
} from "../../api/notifications.api"
|
||||
import {
|
||||
extractApiErrorMessage,
|
||||
fetchAllPlatformUsers,
|
||||
formatScheduledAtLabel,
|
||||
IN_APP_LEVELS,
|
||||
IN_APP_TYPES,
|
||||
parseDirectRecipients,
|
||||
PLATFORM_ROLES,
|
||||
} from "../../lib/notificationBulk"
|
||||
import type {
|
||||
InAppNotificationLevel,
|
||||
NotificationChannel,
|
||||
PlatformRole,
|
||||
} from "../../types/notification.types"
|
||||
import type { UserApiDTO } from "../../types/user.types"
|
||||
|
||||
type AudienceMode = "role" | "selected" | "direct"
|
||||
type SendMode = "now" | "schedule"
|
||||
|
||||
const CHANNELS: {
|
||||
value: NotificationChannel
|
||||
label: string
|
||||
icon: typeof Bell
|
||||
}[] = [
|
||||
{ value: "push", label: "Push", icon: Bell },
|
||||
{ value: "sms", label: "SMS", icon: Mail },
|
||||
{ value: "email", label: "Email", icon: MailOpen },
|
||||
{ value: "in_app", label: "In-app", icon: Smartphone },
|
||||
]
|
||||
|
||||
export function CreateNotificationPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [composeChannels, setComposeChannels] = useState<Array<"push" | "sms">>(["push"])
|
||||
const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all")
|
||||
const [teamRecipients, setTeamRecipients] = useState<TeamMember[]>([])
|
||||
const [channel, setChannel] = useState<NotificationChannel>("push")
|
||||
const [audienceMode, setAudienceMode] = useState<AudienceMode>("role")
|
||||
const [sendMode, setSendMode] = useState<SendMode>("now")
|
||||
const [platformRole, setPlatformRole] = useState<PlatformRole>("STUDENT")
|
||||
const [users, setUsers] = useState<UserApiDTO[]>([])
|
||||
const [recipientsLoading, setRecipientsLoading] = useState(false)
|
||||
const [selectedRecipientIds, setSelectedRecipientIds] = useState<number[]>([])
|
||||
const [composeTitle, setComposeTitle] = useState("")
|
||||
const [composeMessage, setComposeMessage] = useState("")
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([])
|
||||
const [userSearchQuery, setUserSearchQuery] = useState("")
|
||||
const [directRecipients, setDirectRecipients] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [message, setMessage] = useState("")
|
||||
const [htmlBody, setHtmlBody] = useState("")
|
||||
const [inAppType, setInAppType] = useState("system_alert")
|
||||
const [inAppLevel, setInAppLevel] = useState<InAppNotificationLevel>("info")
|
||||
const [scheduledAt, setScheduledAt] = useState("")
|
||||
const [attachment, setAttachment] = useState<File | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [, setComposeImage] = useState<File | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setRecipientsLoading(true)
|
||||
getTeamMembers(1, 50)
|
||||
.then((res) => {
|
||||
setTeamRecipients(res.data.data ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
setTeamRecipients([])
|
||||
})
|
||||
.finally(() => {
|
||||
setRecipientsLoading(false)
|
||||
})
|
||||
fetchAllPlatformUsers()
|
||||
.then(setUsers)
|
||||
.catch(() => setUsers([]))
|
||||
.finally(() => setRecipientsLoading(false))
|
||||
}, [])
|
||||
|
||||
const selectedRecipients = useMemo(
|
||||
() => teamRecipients.filter((m) => selectedRecipientIds.includes(m.id)),
|
||||
[teamRecipients, selectedRecipientIds],
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = userSearchQuery.trim().toLowerCase()
|
||||
if (!q) return users
|
||||
return users.filter((user) => {
|
||||
const fullName = `${user.first_name ?? ""} ${user.last_name ?? ""}`.trim().toLowerCase()
|
||||
const email = (user.email ?? "").toLowerCase()
|
||||
const phone = (user.phone_number ?? "").toLowerCase()
|
||||
return fullName.includes(q) || email.includes(q) || phone.includes(q)
|
||||
})
|
||||
}, [users, userSearchQuery])
|
||||
|
||||
const selectedUsers = useMemo(
|
||||
() => users.filter((u) => selectedUserIds.includes(u.id)),
|
||||
[users, selectedUserIds],
|
||||
)
|
||||
|
||||
const filteredSelectedCount = useMemo(
|
||||
() => filteredUsers.filter((u) => selectedUserIds.includes(u.id)).length,
|
||||
[filteredUsers, selectedUserIds],
|
||||
)
|
||||
|
||||
const needsTitle = channel === "push" || channel === "email" || channel === "in_app"
|
||||
const titleLabel =
|
||||
channel === "email" ? "Subject" : channel === "sms" ? "Title (optional)" : "Title"
|
||||
const supportsDirect = channel === "sms" || channel === "email"
|
||||
const supportsAttachment =
|
||||
(channel === "email" || channel === "push") && sendMode === "now"
|
||||
const isScheduling = sendMode === "schedule"
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle("")
|
||||
setMessage("")
|
||||
setHtmlBody("")
|
||||
setAudienceMode("role")
|
||||
setPlatformRole("STUDENT")
|
||||
setSelectedUserIds([])
|
||||
setUserSearchQuery("")
|
||||
setDirectRecipients("")
|
||||
setScheduledAt("")
|
||||
setAttachment(null)
|
||||
setSendMode("now")
|
||||
setChannel("push")
|
||||
setInAppType("system_alert")
|
||||
setInAppLevel("info")
|
||||
}
|
||||
|
||||
const validateTargeting = (): boolean => {
|
||||
if (audienceMode === "role") return true
|
||||
if (audienceMode === "selected") {
|
||||
if (selectedUserIds.length === 0) {
|
||||
toast.error("Select at least one user")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const direct = parseDirectRecipients(directRecipients)
|
||||
if (direct.length === 0) {
|
||||
toast.error(channel === "sms" ? "Enter at least one phone number" : "Enter at least one email")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const buildTargeting = () => {
|
||||
if (audienceMode === "role") {
|
||||
return { role: platformRole }
|
||||
}
|
||||
if (audienceMode === "selected") {
|
||||
return { user_ids: selectedUserIds }
|
||||
}
|
||||
const direct = parseDirectRecipients(directRecipients)
|
||||
if (channel === "sms") return { phone_numbers: direct }
|
||||
return { emails: direct }
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!composeTitle.trim() || !composeMessage.trim()) return
|
||||
if (composeChannels.length === 0) return
|
||||
setSending(true)
|
||||
|
||||
if (channel !== "sms" && !title.trim()) {
|
||||
toast.error(channel === "email" ? "Subject is required" : "Title is required")
|
||||
return
|
||||
}
|
||||
if (!message.trim() && !(channel === "email" && htmlBody.trim())) {
|
||||
toast.error("Message is required")
|
||||
return
|
||||
}
|
||||
if (isScheduling && !scheduledAt) {
|
||||
toast.error("Choose a schedule date and time")
|
||||
return
|
||||
}
|
||||
if (!validateTargeting()) return
|
||||
|
||||
const targeting = buildTargeting()
|
||||
const schedule = isScheduling && scheduledAt ? { scheduled_at: scheduledAt } : {}
|
||||
|
||||
try {
|
||||
// Hook up to backend send API here when available.
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
setComposeTitle("")
|
||||
setComposeMessage("")
|
||||
setComposeAudience("all")
|
||||
setComposeChannels(["push"])
|
||||
setSelectedRecipientIds([])
|
||||
setComposeImage(null)
|
||||
navigate("/notifications")
|
||||
setSending(true)
|
||||
let result
|
||||
|
||||
if (channel === "sms") {
|
||||
result = await sendBulkSms({
|
||||
message: message.trim(),
|
||||
...targeting,
|
||||
...schedule,
|
||||
} as Parameters<typeof sendBulkSms>[0])
|
||||
} else if (channel === "email") {
|
||||
const form = new FormData()
|
||||
form.append("subject", title.trim())
|
||||
if (message.trim()) form.append("message", message.trim())
|
||||
if (htmlBody.trim()) form.append("html", htmlBody.trim())
|
||||
if ("role" in targeting) form.append("role", targeting.role)
|
||||
if ("user_ids" in targeting) {
|
||||
form.append("user_ids", JSON.stringify(targeting.user_ids))
|
||||
}
|
||||
if ("emails" in targeting) {
|
||||
form.append("emails", JSON.stringify(targeting.emails))
|
||||
}
|
||||
if (isScheduling) form.append("scheduled_at", scheduledAt)
|
||||
if (attachment) form.append("file", attachment)
|
||||
result = await sendBulkEmail(form)
|
||||
} else if (channel === "push") {
|
||||
const form = new FormData()
|
||||
form.append("title", title.trim())
|
||||
form.append("message", message.trim())
|
||||
if ("role" in targeting) form.append("role", targeting.role)
|
||||
if ("user_ids" in targeting) {
|
||||
form.append("user_ids", JSON.stringify(targeting.user_ids))
|
||||
}
|
||||
if (isScheduling) form.append("scheduled_at", scheduledAt)
|
||||
if (attachment) form.append("file", attachment)
|
||||
result = await sendBulkPush(form)
|
||||
} else {
|
||||
result = await sendBulkInApp({
|
||||
title: title.trim(),
|
||||
message: message.trim(),
|
||||
type: inAppType,
|
||||
level: inAppLevel,
|
||||
...targeting,
|
||||
...schedule,
|
||||
} as Parameters<typeof sendBulkInApp>[0])
|
||||
}
|
||||
|
||||
if (result.kind === "scheduled") {
|
||||
toast.success("Notification scheduled", {
|
||||
description: `Job #${result.data.id} · ${new Date(result.data.scheduled_at).toLocaleString()}`,
|
||||
action: {
|
||||
label: "View scheduled",
|
||||
onClick: () => navigate("/notifications/scheduled"),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const { data } = result
|
||||
const total =
|
||||
data.total_recipients ?? data.target_users ?? data.sent + data.failed
|
||||
toast.success("Notification sent", {
|
||||
description: `${data.sent} sent · ${data.failed} failed · ${total} recipient${total === 1 ? "" : "s"}`,
|
||||
})
|
||||
}
|
||||
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
toast.error("Failed to send notification", {
|
||||
description: extractApiErrorMessage(err, "Please try again."),
|
||||
})
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const previewIcon = CHANNELS.find((c) => c.value === channel)?.icon ?? Bell
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl space-y-5">
|
||||
{/* Breadcrumb + Header */}
|
||||
<div className="space-y-2">
|
||||
<nav className="flex items-center gap-1 text-xs text-grayScale-400">
|
||||
<button
|
||||
|
|
@ -84,7 +267,7 @@ export function CreateNotificationPage() {
|
|||
Notifications
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-grayScale-500">Create</span>
|
||||
<span className="text-grayScale-500">Send</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
|
@ -93,25 +276,29 @@ export function CreateNotificationPage() {
|
|||
Notifications
|
||||
</p>
|
||||
<h1 className="mt-1 flex items-center gap-2 text-2xl font-semibold tracking-tight text-grayScale-700">
|
||||
Create notification
|
||||
Send notification
|
||||
<span className="inline-flex h-7 items-center gap-1 rounded-full bg-brand-500/90 px-2 text-[11px] font-medium text-white">
|
||||
<Megaphone className="h-3.5 w-3.5" />
|
||||
Composer
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-grayScale-400">
|
||||
Send a one-off push or SMS notification to your users.
|
||||
Send or schedule bulk SMS, email, push, or in-app notifications.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() => navigate("/notifications")}
|
||||
>
|
||||
Back to notifications
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/notifications/scheduled">Scheduled jobs</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate("/notifications")}
|
||||
>
|
||||
Back to inbox
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -119,103 +306,106 @@ export function CreateNotificationPage() {
|
|||
onSubmit={handleSubmit}
|
||||
className="grid gap-4 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1.1fr)]"
|
||||
>
|
||||
{/* Left: message setup */}
|
||||
<div className="space-y-4">
|
||||
<Card className="shadow-none border border-grayScale-100">
|
||||
<Card className="border border-grayScale-100 shadow-none">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
{/* Channel & audience */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||
Channel
|
||||
</p>
|
||||
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setComposeChannels((prev) =>
|
||||
prev.includes("push")
|
||||
? prev.filter((c) => c !== "push")
|
||||
: [...prev, "push"],
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
composeChannels.includes("push")
|
||||
? "bg-brand-500 text-white shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
Push
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setComposeChannels((prev) =>
|
||||
prev.includes("sms")
|
||||
? prev.filter((c) => c !== "sms")
|
||||
: [...prev, "sms"],
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
composeChannels.includes("sms")
|
||||
? "bg-brand-500 text-white shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
SMS
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-1 rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
|
||||
{CHANNELS.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannel(value)
|
||||
if (value !== "sms" && value !== "email" && audienceMode === "direct") {
|
||||
setAudienceMode("role")
|
||||
}
|
||||
if (value === "sms") setTitle("")
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
channel === value
|
||||
? "bg-brand-500 text-white shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||
Audience
|
||||
Delivery
|
||||
</p>
|
||||
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setComposeAudience("all")}
|
||||
onClick={() => {
|
||||
setSendMode("now")
|
||||
setScheduledAt("")
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
composeAudience === "all"
|
||||
sendMode === "now"
|
||||
? "bg-brand-500 text-white shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
All users
|
||||
Send now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setComposeAudience("selected")}
|
||||
onClick={() => setSendMode("schedule")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
composeAudience === "selected"
|
||||
sendMode === "schedule"
|
||||
? "bg-brand-500 text-white shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
Selected users
|
||||
<CalendarClock className="h-3.5 w-3.5" />
|
||||
Schedule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title & message */}
|
||||
<div className="space-y-3">
|
||||
{isScheduling && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Title
|
||||
Scheduled at (UTC)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Short headline for this notification"
|
||||
value={composeTitle}
|
||||
onChange={(e) => setComposeTitle(e.target.value)}
|
||||
/>
|
||||
<NotificationSchedulePicker value={scheduledAt} onChange={setScheduledAt} />
|
||||
<p className="mt-1 text-[10px] text-grayScale-400">
|
||||
Attachments and push images are not supported for scheduled sends.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{needsTitle && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
{titleLabel}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={
|
||||
channel === "email"
|
||||
? "Email subject line"
|
||||
: "Short headline for this notification"
|
||||
}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Message
|
||||
|
|
@ -223,73 +413,112 @@ export function CreateNotificationPage() {
|
|||
<Textarea
|
||||
rows={4}
|
||||
placeholder={
|
||||
composeChannels.includes("sms") && !composeChannels.includes("push")
|
||||
? "Concise SMS body. Keep it clear and under 160 characters where possible."
|
||||
: "Notification body shown inside the app."
|
||||
channel === "sms"
|
||||
? "SMS body text."
|
||||
: "Notification body shown to recipients."
|
||||
}
|
||||
value={composeMessage}
|
||||
onChange={(e) => setComposeMessage(e.target.value)}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{channel === "email" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
HTML body (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder="<p>Rich HTML content</p>"
|
||||
value={htmlBody}
|
||||
onChange={(e) => setHtmlBody(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{channel === "in_app" && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Type
|
||||
</label>
|
||||
<Select value={inAppType} onChange={(e) => setInAppType(e.target.value)}>
|
||||
{IN_APP_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Level
|
||||
</label>
|
||||
<Select
|
||||
value={inAppLevel}
|
||||
onChange={(e) =>
|
||||
setInAppLevel(e.target.value as InAppNotificationLevel)
|
||||
}
|
||||
>
|
||||
{IN_APP_LEVELS.map((l) => (
|
||||
<option key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Image upload */}
|
||||
<Card className="shadow-none border border-grayScale-100">
|
||||
<CardContent className="space-y-2 p-4">
|
||||
<p className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Image (push only)
|
||||
</p>
|
||||
<FileUpload
|
||||
accept="image/*"
|
||||
onFileSelect={setComposeImage}
|
||||
label="Upload notification image"
|
||||
description="Shown with push notification where supported"
|
||||
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||
/>
|
||||
<p className="text-[10px] text-grayScale-400">
|
||||
Image will be ignored for SMS-only sends. Connect your push provider to attach it to
|
||||
real notifications.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{supportsAttachment && (
|
||||
<Card className="border border-grayScale-100 shadow-none">
|
||||
<CardContent className="space-y-2 p-4">
|
||||
<p className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
{channel === "push" ? "Image (push only)" : "Attachment (email only)"}
|
||||
</p>
|
||||
<FileUpload
|
||||
accept={channel === "push" ? "image/*" : undefined}
|
||||
onFileSelect={setAttachment}
|
||||
label={channel === "push" ? "Upload notification image" : "Upload attachment"}
|
||||
description={
|
||||
channel === "push"
|
||||
? "Shown with push notification where supported"
|
||||
: "Optional file attached to the email"
|
||||
}
|
||||
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
|
||||
<p className="text-[11px] text-grayScale-400">
|
||||
This is a UI-only preview. Hook into your notification API to deliver messages.
|
||||
{isScheduling
|
||||
? "Creates a scheduled job processed by the backend worker."
|
||||
: "Delivers immediately with sent/failed counts returned."}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setComposeTitle("")
|
||||
setComposeMessage("")
|
||||
setComposeAudience("all")
|
||||
setComposeChannels(["push"])
|
||||
setSelectedRecipientIds([])
|
||||
setComposeImage(null)
|
||||
}}
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" onClick={resetForm} disabled={sending}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={sending || !composeTitle.trim() || !composeMessage.trim()}
|
||||
disabled={
|
||||
sending ||
|
||||
(!message.trim() && !(channel === "email" && htmlBody.trim()))
|
||||
}
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
|
||||
Sending…
|
||||
{isScheduling ? "Scheduling…" : "Sending…"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MailOpen className="mr-2 h-3.5 w-3.5" />
|
||||
Send notification
|
||||
{isScheduling ? "Schedule notification" : "Send notification"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -297,122 +526,233 @@ export function CreateNotificationPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: audience & preview */}
|
||||
<div className="space-y-4">
|
||||
<Card className="shadow-none border border-grayScale-100">
|
||||
<Card className="border border-grayScale-100 shadow-none">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-grayScale-600">Audience & channels</p>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-grayScale-100 px-2 py-0.5 text-[10px] font-medium text-grayScale-500">
|
||||
{composeChannels.join(" + ").toUpperCase() || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-[11px] text-grayScale-500">
|
||||
<p>
|
||||
<span className="font-semibold text-grayScale-600">Audience:</span>{" "}
|
||||
{composeAudience === "all"
|
||||
? "All users"
|
||||
: selectedRecipients.length === 0
|
||||
? "No users selected yet"
|
||||
: `${selectedRecipients.length} selected user${
|
||||
selectedRecipients.length === 1 ? "" : "s"
|
||||
}`}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold text-grayScale-600">Channels:</span>{" "}
|
||||
{composeChannels.length === 0
|
||||
? "None selected"
|
||||
: composeChannels.map((c) => c.toUpperCase()).join(" + ")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none border border-grayScale-100">
|
||||
<CardContent className="space-y-2 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-grayScale-600">Selected users</p>
|
||||
{composeAudience === "selected" && (
|
||||
<span className="text-[10px] text-grayScale-400">
|
||||
{selectedRecipients.length} selected
|
||||
</span>
|
||||
<p className="text-xs font-semibold text-grayScale-600">Audience</p>
|
||||
<div className="inline-flex flex-wrap gap-1 rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAudienceMode("role")}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1.5 transition-colors",
|
||||
audienceMode === "role"
|
||||
? "bg-brand-500 text-white shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
By role
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAudienceMode("selected")
|
||||
setUserSearchQuery("")
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1.5 transition-colors",
|
||||
audienceMode === "selected"
|
||||
? "bg-brand-500 text-white shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
Selected users
|
||||
</button>
|
||||
{supportsDirect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAudienceMode("direct")}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1.5 transition-colors",
|
||||
audienceMode === "direct"
|
||||
? "bg-brand-500 text-white shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
Direct {channel === "sms" ? "phones" : "emails"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{composeAudience === "all" ? (
|
||||
<p className="text-[11px] text-grayScale-400">
|
||||
All eligible users will receive this notification. Switch to{" "}
|
||||
<span className="font-semibold text-grayScale-600">Selected users</span> to target
|
||||
specific people.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
|
||||
{recipientsLoading && (
|
||||
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
|
||||
<SpinnerIcon className="mr-2 h-4 w-4" alt="" />
|
||||
Loading users…
|
||||
</div>
|
||||
)}
|
||||
{!recipientsLoading && teamRecipients.length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-grayScale-400">
|
||||
No users available to select.
|
||||
</div>
|
||||
)}
|
||||
{!recipientsLoading &&
|
||||
teamRecipients.map((member) => {
|
||||
const checked = selectedRecipientIds.includes(member.id)
|
||||
return (
|
||||
<label
|
||||
key={member.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs",
|
||||
checked ? "bg-brand-50 text-brand-700" : "hover:bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 rounded border-grayScale-300"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
setSelectedRecipientIds((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, member.id]
|
||||
: prev.filter((id) => id !== member.id),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{member.first_name} {member.last_name}
|
||||
<span className="ml-1 text-[10px] text-grayScale-400">
|
||||
· {member.email}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
|
||||
{audienceMode === "role" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Platform role
|
||||
</label>
|
||||
<Select
|
||||
value={platformRole}
|
||||
onChange={(e) => setPlatformRole(e.target.value as PlatformRole)}
|
||||
>
|
||||
{PLATFORM_ROLES.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-grayScale-400">
|
||||
Sends to all users with this platform role.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audienceMode === "direct" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
{channel === "sms" ? "Phone numbers" : "Email addresses"}
|
||||
</label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder={
|
||||
channel === "sms"
|
||||
? "+251911000000\n+251922000000"
|
||||
: "user@example.com\nadmin@example.com"
|
||||
}
|
||||
value={directRecipients}
|
||||
onChange={(e) => setDirectRecipients(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-grayScale-400">
|
||||
One per line or comma-separated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mobile preview card */}
|
||||
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/40">
|
||||
{audienceMode === "selected" && (
|
||||
<Card className="border border-grayScale-100 shadow-none">
|
||||
<CardContent className="space-y-2 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-grayScale-600">Selected users</p>
|
||||
<span className="text-[10px] text-grayScale-400">
|
||||
{selectedUsers.length} selected
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-grayScale-100 bg-grayScale-50/60">
|
||||
<div className="sticky top-0 z-10 space-y-2 border-b border-grayScale-100 bg-grayScale-50/95 p-2 backdrop-blur-sm">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-grayScale-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name, email, or phone…"
|
||||
value={userSearchQuery}
|
||||
onChange={(e) => setUserSearchQuery(e.target.value)}
|
||||
className="h-8 border-grayScale-200 bg-white pl-8 text-xs"
|
||||
disabled={recipientsLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={recipientsLoading || filteredUsers.length === 0}
|
||||
onClick={() => {
|
||||
const ids = filteredUsers.map((u) => u.id)
|
||||
setSelectedUserIds((prev) => [...new Set([...prev, ...ids])])
|
||||
}}
|
||||
className="text-[11px] font-medium text-brand-600 hover:text-brand-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<span className="text-grayScale-300">·</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={recipientsLoading || filteredSelectedCount === 0}
|
||||
onClick={() => {
|
||||
const filteredIds = new Set(filteredUsers.map((u) => u.id))
|
||||
setSelectedUserIds((prev) => prev.filter((id) => !filteredIds.has(id)))
|
||||
}}
|
||||
className="text-[11px] font-medium text-grayScale-500 hover:text-grayScale-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Unselect all
|
||||
</button>
|
||||
{userSearchQuery.trim() && filteredUsers.length > 0 && (
|
||||
<span className="ml-auto text-[10px] text-grayScale-400">
|
||||
{filteredUsers.length} shown
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 space-y-1.5 overflow-y-auto p-2">
|
||||
{recipientsLoading && (
|
||||
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
|
||||
<SpinnerIcon className="mr-2 h-4 w-4" alt="" />
|
||||
Loading users…
|
||||
</div>
|
||||
)}
|
||||
{!recipientsLoading && users.length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-grayScale-400">
|
||||
No users available to select.
|
||||
</div>
|
||||
)}
|
||||
{!recipientsLoading && users.length > 0 && filteredUsers.length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-grayScale-400">
|
||||
No users match “{userSearchQuery.trim()}”.
|
||||
</div>
|
||||
)}
|
||||
{!recipientsLoading &&
|
||||
filteredUsers.map((user) => {
|
||||
const checked = selectedUserIds.includes(user.id)
|
||||
return (
|
||||
<label
|
||||
key={user.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs",
|
||||
checked ? "bg-brand-50 text-brand-700" : "hover:bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 rounded border-grayScale-300"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
setSelectedUserIds((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, user.id]
|
||||
: prev.filter((id) => id !== user.id),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{user.first_name} {user.last_name}
|
||||
<span className="ml-1 text-[10px] text-grayScale-400">
|
||||
· {user.email ?? user.phone_number ?? `ID ${user.id}`}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="border border-dashed border-grayScale-200 bg-grayScale-50/40 shadow-none">
|
||||
<CardContent className="space-y-2 p-4">
|
||||
<p className="text-xs font-semibold text-grayScale-600">Preview</p>
|
||||
<div className="space-y-1 rounded-xl border border-grayScale-200 bg-white p-3 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-brand-500/90 text-white">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
{(() => {
|
||||
const Icon = previewIcon
|
||||
return <Icon className="h-3.5 w-3.5" />
|
||||
})()}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold text-grayScale-800">
|
||||
{composeTitle || "Notification title"}
|
||||
{title || (channel === "sms" ? "SMS message" : "Notification title")}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-grayScale-500">
|
||||
{composeMessage || "Message preview will appear here."}
|
||||
{message || "Message preview will appear here."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-grayScale-400">
|
||||
Channel: {channel.toUpperCase().replace("_", "-")}
|
||||
{isScheduling && scheduledAt
|
||||
? ` · Scheduled ${formatScheduledAtLabel(scheduledAt)}`
|
||||
: ""}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -420,4 +760,3 @@ export function CreateNotificationPage() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,46 +1,29 @@
|
|||
import { useEffect, useState, useCallback, useMemo } from "react"
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
AlertTriangle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Megaphone,
|
||||
MailOpen,
|
||||
Mail,
|
||||
CheckCheck,
|
||||
MailX,
|
||||
Search,
|
||||
ChevronDown,
|
||||
Calendar,
|
||||
Clock3,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../components/ui/dropdown-menu"
|
||||
import { FileUpload } from "../../components/ui/file-upload"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
|
|
@ -52,9 +35,6 @@ import {
|
|||
markAsUnread,
|
||||
markAllRead,
|
||||
markAllUnread,
|
||||
sendBulkSms,
|
||||
sendBulkEmail,
|
||||
sendBulkPush,
|
||||
} from "../../api/notifications.api"
|
||||
import { NotificationDetailDialog } from "../../components/notifications/NotificationDetailDialog"
|
||||
import {
|
||||
|
|
@ -64,20 +44,10 @@ import {
|
|||
getNotificationLevelBadge,
|
||||
NOTIFICATION_TYPE_CONFIG,
|
||||
} from "../../lib/notificationDisplay"
|
||||
import { getRoles } from "../../api/rbac.api"
|
||||
import { getTeamMembers } from "../../api/team.api"
|
||||
import { getUsers } from "../../api/users.api"
|
||||
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
|
||||
import type { Role } from "../../types/rbac.types"
|
||||
import type { TeamMember } from "../../types/team.types"
|
||||
import type { UserApiDTO } from "../../types/user.types"
|
||||
import { toast } from "sonner"
|
||||
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
|
||||
function digitsOnly(value: string, maxLength: number) {
|
||||
return value.replace(/\D/g, "").slice(0, maxLength)
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onToggleRead,
|
||||
|
|
@ -220,152 +190,6 @@ export function NotificationsPage() {
|
|||
const [typeFilter, setTypeFilter] = useState<"all" | string>("all")
|
||||
const [levelFilter, setLevelFilter] = useState<"all" | string>("all")
|
||||
|
||||
const [bulkOpen, setBulkOpen] = useState(false)
|
||||
const [bulkChannel, setBulkChannel] = useState<"sms" | "email" | "push">("sms")
|
||||
const [bulkTitle, setBulkTitle] = useState("")
|
||||
const [bulkMessage, setBulkMessage] = useState("")
|
||||
const [bulkRole, setBulkRole] = useState("")
|
||||
const [bulkUserIds, setBulkUserIds] = useState<number[]>([])
|
||||
const [bulkScheduledAt, setBulkScheduledAt] = useState("")
|
||||
const [bulkFile, setBulkFile] = useState<File | null>(null)
|
||||
const [bulkSending, setBulkSending] = useState(false)
|
||||
const [bulkRoles, setBulkRoles] = useState<Role[]>([])
|
||||
const [bulkUsers, setBulkUsers] = useState<UserApiDTO[]>([])
|
||||
const [bulkRolesLoading, setBulkRolesLoading] = useState(false)
|
||||
const [bulkUsersLoading, setBulkUsersLoading] = useState(false)
|
||||
const [scheduleMenuOpen, setScheduleMenuOpen] = useState(false)
|
||||
const [scheduleYear, setScheduleYear] = useState("")
|
||||
const [scheduleMonth, setScheduleMonth] = useState("")
|
||||
const [scheduleDay, setScheduleDay] = useState("")
|
||||
const [scheduleHour, setScheduleHour] = useState("")
|
||||
const [scheduleMinute, setScheduleMinute] = useState("")
|
||||
|
||||
const filteredBulkUsers = useMemo(() => {
|
||||
if (!bulkRole.trim()) return bulkUsers
|
||||
const selectedRole = bulkRole.trim().toLowerCase()
|
||||
return bulkUsers.filter((user) => user.role?.toLowerCase() === selectedRole)
|
||||
}, [bulkUsers, bulkRole])
|
||||
|
||||
const scheduledAtLabel = useMemo(() => {
|
||||
if (!bulkScheduledAt) return "Set date & time"
|
||||
const parsed = new Date(bulkScheduledAt)
|
||||
if (Number.isNaN(parsed.getTime())) return bulkScheduledAt
|
||||
return parsed.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}, [bulkScheduledAt])
|
||||
|
||||
const loadBulkOptions = useCallback(async () => {
|
||||
if (!bulkOpen) return
|
||||
|
||||
const needsRoles = bulkRoles.length === 0
|
||||
const needsUsers = bulkUsers.length === 0
|
||||
if (!needsRoles && !needsUsers) return
|
||||
|
||||
try {
|
||||
if (needsRoles) setBulkRolesLoading(true)
|
||||
if (needsUsers) setBulkUsersLoading(true)
|
||||
|
||||
const tasks: Promise<unknown>[] = []
|
||||
if (needsRoles) {
|
||||
tasks.push(
|
||||
getRoles({ page: 1, page_size: 20 })
|
||||
.then(async (res) => {
|
||||
const firstBatch = res.data?.data?.roles ?? []
|
||||
const total = res.data?.data?.total ?? firstBatch.length
|
||||
const pageSize = 20
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
if (totalPages <= 1) {
|
||||
setBulkRoles(firstBatch)
|
||||
return
|
||||
}
|
||||
|
||||
const remainingRequests: Array<ReturnType<typeof getRoles>> = []
|
||||
for (let page = 2; page <= totalPages; page += 1) {
|
||||
remainingRequests.push(getRoles({ page, page_size: pageSize }))
|
||||
}
|
||||
|
||||
try {
|
||||
const responses = await Promise.all(remainingRequests)
|
||||
const rest = responses.flatMap((r) => r.data?.data?.roles ?? [])
|
||||
setBulkRoles([...firstBatch, ...rest])
|
||||
} catch {
|
||||
setBulkRoles(firstBatch)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setBulkRoles([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (needsUsers) {
|
||||
tasks.push(
|
||||
getUsers({ page: 1, page_size: 20 })
|
||||
.then(async (res) => {
|
||||
const firstBatch = res.data?.data?.users ?? []
|
||||
const total = res.data?.data?.total ?? firstBatch.length
|
||||
const pageSize = 20
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
if (totalPages <= 1) {
|
||||
setBulkUsers(firstBatch)
|
||||
return
|
||||
}
|
||||
|
||||
const remainingRequests: Array<ReturnType<typeof getUsers>> = []
|
||||
for (let page = 2; page <= totalPages; page += 1) {
|
||||
remainingRequests.push(getUsers({ page, page_size: pageSize }))
|
||||
}
|
||||
|
||||
try {
|
||||
const responses = await Promise.all(remainingRequests)
|
||||
const rest = responses.flatMap((r) => r.data?.data?.users ?? [])
|
||||
setBulkUsers([...firstBatch, ...rest])
|
||||
} catch {
|
||||
setBulkUsers(firstBatch)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setBulkUsers([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(tasks)
|
||||
} finally {
|
||||
setBulkRolesLoading(false)
|
||||
setBulkUsersLoading(false)
|
||||
}
|
||||
}, [bulkOpen, bulkRoles.length, bulkUsers.length])
|
||||
|
||||
useEffect(() => {
|
||||
loadBulkOptions()
|
||||
}, [loadBulkOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!scheduleMenuOpen) return
|
||||
if (!bulkScheduledAt) {
|
||||
setScheduleYear("")
|
||||
setScheduleMonth("")
|
||||
setScheduleDay("")
|
||||
setScheduleHour("")
|
||||
setScheduleMinute("")
|
||||
return
|
||||
}
|
||||
const [datePart = "", timePart = ""] = bulkScheduledAt.split("T")
|
||||
const [y = "", m = "", d = ""] = datePart.split("-")
|
||||
const [hh = "", mm = ""] = timePart.split(":")
|
||||
setScheduleYear(y)
|
||||
setScheduleMonth(m)
|
||||
setScheduleDay(d)
|
||||
setScheduleHour(hh)
|
||||
setScheduleMinute(mm.slice(0, 2))
|
||||
}, [scheduleMenuOpen, bulkScheduledAt])
|
||||
|
||||
const fetchData = useCallback(async (currentOffset: number) => {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
|
|
@ -992,487 +816,6 @@ export function NotificationsPage() {
|
|||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Bulk send dialog */}
|
||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Megaphone className="h-5 w-5 text-brand-500" />
|
||||
<span>Send notification</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a bulk SMS, email, or push notification to users.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
if (!bulkMessage.trim()) {
|
||||
toast.error("Message is required")
|
||||
return
|
||||
}
|
||||
const userIds = bulkUserIds
|
||||
|
||||
try {
|
||||
setBulkSending(true)
|
||||
|
||||
if (bulkChannel === "sms") {
|
||||
if (userIds.length === 0) {
|
||||
toast.error("User IDs are required for bulk SMS")
|
||||
setBulkSending(false)
|
||||
return
|
||||
}
|
||||
await sendBulkSms({
|
||||
message: bulkMessage.trim(),
|
||||
user_ids: userIds,
|
||||
...(bulkScheduledAt ? { scheduled_at: bulkScheduledAt } : {}),
|
||||
})
|
||||
} else if (bulkChannel === "email") {
|
||||
const form = new FormData()
|
||||
if (!bulkTitle.trim()) {
|
||||
toast.error("Subject is required for bulk email")
|
||||
setBulkSending(false)
|
||||
return
|
||||
}
|
||||
form.append("subject", bulkTitle.trim())
|
||||
form.append("message", bulkMessage.trim())
|
||||
if (bulkRole.trim()) form.append("role", bulkRole.trim())
|
||||
if (userIds.length > 0) {
|
||||
form.append("user_ids", JSON.stringify(userIds))
|
||||
}
|
||||
if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt)
|
||||
if (bulkFile) form.append("file", bulkFile)
|
||||
await sendBulkEmail(form)
|
||||
} else {
|
||||
const form = new FormData()
|
||||
if (!bulkTitle.trim()) {
|
||||
toast.error("Title is required for bulk push")
|
||||
setBulkSending(false)
|
||||
return
|
||||
}
|
||||
form.append("title", bulkTitle.trim())
|
||||
form.append("message", bulkMessage.trim())
|
||||
if (bulkRole.trim()) form.append("role", bulkRole.trim())
|
||||
if (userIds.length > 0) {
|
||||
form.append("user_ids", JSON.stringify(userIds))
|
||||
}
|
||||
if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt)
|
||||
if (bulkFile) form.append("file", bulkFile)
|
||||
await sendBulkPush(form)
|
||||
}
|
||||
|
||||
toast.success("Notification scheduled", {
|
||||
description: bulkScheduledAt
|
||||
? "Notification has been scheduled successfully."
|
||||
: "Notification has been sent successfully.",
|
||||
})
|
||||
|
||||
setBulkTitle("")
|
||||
setBulkMessage("")
|
||||
setBulkRole("")
|
||||
setBulkUserIds([])
|
||||
setBulkScheduledAt("")
|
||||
setBulkFile(null)
|
||||
setBulkChannel("sms")
|
||||
setBulkOpen(false)
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
err?.response?.data?.message ||
|
||||
"Failed to send notification. Please try again."
|
||||
toast.error("Failed to send notification", { description: msg })
|
||||
} finally {
|
||||
setBulkSending(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.3fr)]">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Channel
|
||||
</label>
|
||||
<Select
|
||||
value={bulkChannel}
|
||||
onChange={(e) => setBulkChannel(e.target.value as typeof bulkChannel)}
|
||||
>
|
||||
<option value="sms">Bulk SMS</option>
|
||||
<option value="email">Bulk email</option>
|
||||
<option value="push">Bulk push</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
{bulkChannel === "email" ? "Subject" : "Title (push only)"}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={
|
||||
bulkChannel === "email"
|
||||
? `e.g. "System Update"`
|
||||
: `e.g. "System Update"`
|
||||
}
|
||||
value={bulkTitle}
|
||||
onChange={(e) => setBulkTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Message
|
||||
</label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={
|
||||
bulkChannel === "sms"
|
||||
? "Text body to send by SMS."
|
||||
: "Notification body for email or push."
|
||||
}
|
||||
value={bulkMessage}
|
||||
onChange={(e) => setBulkMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Role (optional)
|
||||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkRolesLoading}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-lg border bg-white px-3 text-sm",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
bulkRolesLoading && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{bulkRolesLoading ? "Loading roles..." : bulkRole || "All roles"}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-grayScale-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[220px]">
|
||||
<DropdownMenuLabel>Roles</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={bulkRole}
|
||||
onValueChange={(value) => {
|
||||
setBulkRole(value)
|
||||
setBulkUserIds([])
|
||||
}}
|
||||
>
|
||||
<DropdownMenuRadioItem value="">All roles</DropdownMenuRadioItem>
|
||||
{bulkRoles.map((role) => (
|
||||
<DropdownMenuRadioItem key={role.id} value={role.name}>
|
||||
{role.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Users (optional)
|
||||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkUsersLoading}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-lg border bg-white px-3 text-sm",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
bulkUsersLoading && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{bulkUsersLoading
|
||||
? "Loading users..."
|
||||
: bulkUserIds.length === 0
|
||||
? "Select users"
|
||||
: `${bulkUserIds.length} user(s) selected`}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-grayScale-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[320px]">
|
||||
<DropdownMenuLabel>Users</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setBulkUserIds(filteredBulkUsers.map((u) => u.id))
|
||||
}}
|
||||
disabled={filteredBulkUsers.length === 0}
|
||||
>
|
||||
Select all
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setBulkUserIds([])
|
||||
}}
|
||||
disabled={bulkUserIds.length === 0}
|
||||
>
|
||||
Deselect all
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{filteredBulkUsers.length === 0 ? (
|
||||
<p className="px-2 py-2 text-xs text-grayScale-400">No users available</p>
|
||||
) : (
|
||||
filteredBulkUsers.map((user) => {
|
||||
const isChecked = bulkUserIds.includes(user.id)
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={user.id}
|
||||
checked={isChecked}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(checked) => {
|
||||
setBulkUserIds((prev) => {
|
||||
if (checked) return prev.includes(user.id) ? prev : [...prev, user.id]
|
||||
return prev.filter((id) => id !== user.id)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{user.first_name} {user.last_name} ({user.id})
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-grayScale-400">Choose one or more users from the dropdown list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)]">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
File attachment (optional)
|
||||
</label>
|
||||
<FileUpload
|
||||
accept="image/*"
|
||||
onFileSelect={setBulkFile}
|
||||
label="Upload image or file"
|
||||
description="Optional image or asset to attach"
|
||||
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Scheduled at (optional)
|
||||
</label>
|
||||
<DropdownMenu open={scheduleMenuOpen} onOpenChange={setScheduleMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-grayScale-50/70 px-3 text-sm text-grayScale-700 shadow-sm transition-all",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-100",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">{scheduledAtLabel}</span>
|
||||
<span className="ml-2 inline-flex items-center gap-1 rounded-md border border-grayScale-200 bg-white px-2 py-1 text-[11px] text-grayScale-500">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[320px] p-3">
|
||||
<p className="mb-2 text-xs font-semibold text-grayScale-500">Schedule notification</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Date</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="YYYY"
|
||||
value={scheduleYear}
|
||||
onChange={(e) => setScheduleYear(digitsOnly(e.target.value, 4))}
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
className="h-9 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
||||
/>
|
||||
<span className="text-grayScale-400">-</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="MM"
|
||||
value={scheduleMonth}
|
||||
onChange={(e) => setScheduleMonth(digitsOnly(e.target.value, 2))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
||||
/>
|
||||
<span className="text-grayScale-400">-</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="DD"
|
||||
value={scheduleDay}
|
||||
onChange={(e) => setScheduleDay(digitsOnly(e.target.value, 2))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Time</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="HH"
|
||||
value={scheduleHour}
|
||||
onChange={(e) => setScheduleHour(digitsOnly(e.target.value, 2))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
||||
/>
|
||||
<span className="text-grayScale-400">:</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="MM"
|
||||
value={scheduleMinute}
|
||||
onChange={(e) => setScheduleMinute(digitsOnly(e.target.value, 2))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
const now = new Date()
|
||||
setScheduleYear(String(now.getFullYear()))
|
||||
setScheduleMonth(String(now.getMonth() + 1).padStart(2, "0"))
|
||||
setScheduleDay(String(now.getDate()).padStart(2, "0"))
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setScheduleYear("")
|
||||
setScheduleMonth("")
|
||||
setScheduleDay("")
|
||||
setScheduleHour("")
|
||||
setScheduleMinute("")
|
||||
setBulkScheduledAt("")
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
const year = Number(scheduleYear)
|
||||
const month = Number(scheduleMonth)
|
||||
const day = Number(scheduleDay)
|
||||
const hour = Number(scheduleHour)
|
||||
const minute = Number(scheduleMinute)
|
||||
|
||||
const formatOk =
|
||||
scheduleYear.length === 4 &&
|
||||
scheduleMonth.length === 2 &&
|
||||
scheduleDay.length === 2 &&
|
||||
scheduleHour.length === 2 &&
|
||||
scheduleMinute.length === 2
|
||||
const dateValue = new Date(year, month - 1, day)
|
||||
const dateOk =
|
||||
formatOk &&
|
||||
month >= 1 &&
|
||||
month <= 12 &&
|
||||
day >= 1 &&
|
||||
day <= 31 &&
|
||||
dateValue.getFullYear() === year &&
|
||||
dateValue.getMonth() === month - 1 &&
|
||||
dateValue.getDate() === day
|
||||
const timeOk = hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59
|
||||
|
||||
if (!dateOk || !timeOk) {
|
||||
toast.error("Use valid date/time format", {
|
||||
description: "Date: YYYY-MM-DD, Time: HH:MM (24h).",
|
||||
})
|
||||
return
|
||||
}
|
||||
setBulkScheduledAt(
|
||||
`${scheduleYear}-${scheduleMonth}-${scheduleDay}T${scheduleHour}:${scheduleMinute}`,
|
||||
)
|
||||
setScheduleMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<p className="text-[11px] text-grayScale-400">
|
||||
Leave empty to send immediately. When set, the notification is stored in{" "}
|
||||
<code>scheduled_notifications</code> and sent at the specified time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBulkTitle("")
|
||||
setBulkMessage("")
|
||||
setBulkRole("")
|
||||
setBulkUserIds([])
|
||||
setBulkScheduledAt("")
|
||||
setBulkFile(null)
|
||||
setBulkChannel("sms")
|
||||
setBulkOpen(false)
|
||||
}}
|
||||
disabled={bulkSending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={bulkSending || !bulkMessage.trim()}>
|
||||
{bulkSending ? (
|
||||
<>
|
||||
<SpinnerIcon className="mr-2 h-3.5 w-3.5" />
|
||||
Sending…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MailOpen className="mr-2 h-3.5 w-3.5" />
|
||||
Send
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
318
src/pages/notifications/ScheduledNotificationsPage.tsx
Normal file
318
src/pages/notifications/ScheduledNotificationsPage.tsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import {
|
||||
CalendarClock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Megaphone,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
cancelScheduledNotification,
|
||||
getScheduledNotifications,
|
||||
} from "../../api/notifications.api"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table"
|
||||
import {
|
||||
channelLabel,
|
||||
formatScheduledAtLabel,
|
||||
scheduledStatusBadgeVariant,
|
||||
} from "../../lib/notificationBulk"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { DEFAULT_TABLE_PAGE_SIZE } from "../../lib/tablePagination"
|
||||
import type {
|
||||
NotificationChannel,
|
||||
ScheduledNotification,
|
||||
ScheduledNotificationStatus,
|
||||
} from "../../types/notification.types"
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: "" | ScheduledNotificationStatus; label: string }> = [
|
||||
{ value: "", label: "All statuses" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "processing", label: "Processing" },
|
||||
{ value: "sent", label: "Sent" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
]
|
||||
|
||||
const CHANNEL_OPTIONS: Array<{ value: "" | NotificationChannel; label: string }> = [
|
||||
{ value: "", label: "All channels" },
|
||||
{ value: "sms", label: "SMS" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "push", label: "Push" },
|
||||
{ value: "in_app", label: "In-app" },
|
||||
]
|
||||
|
||||
export function ScheduledNotificationsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const [rows, setRows] = useState<ScheduledNotification[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [statusFilter, setStatusFilter] = useState<"" | ScheduledNotificationStatus>("")
|
||||
const [channelFilter, setChannelFilter] = useState<"" | NotificationChannel>("")
|
||||
const [cancellingId, setCancellingId] = useState<number | null>(null)
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(totalCount / limit)),
|
||||
[totalCount, limit],
|
||||
)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
const res = await getScheduledNotifications({
|
||||
page,
|
||||
limit,
|
||||
...(statusFilter ? { status: statusFilter } : {}),
|
||||
...(channelFilter ? { channel: channelFilter } : {}),
|
||||
})
|
||||
setRows(res.data.scheduled_notifications)
|
||||
setTotalCount(res.data.total_count)
|
||||
} catch {
|
||||
setError(true)
|
||||
setRows([])
|
||||
setTotalCount(0)
|
||||
toast.error("Failed to load scheduled notifications")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, limit, statusFilter, channelFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [statusFilter, channelFilter])
|
||||
|
||||
const handleCancel = async (job: ScheduledNotification) => {
|
||||
if (job.status !== "pending" && job.status !== "processing") return
|
||||
try {
|
||||
setCancellingId(job.id)
|
||||
await cancelScheduledNotification(job.id)
|
||||
toast.success("Scheduled notification cancelled", {
|
||||
description: `Job #${job.id} was cancelled.`,
|
||||
})
|
||||
await load()
|
||||
} catch {
|
||||
toast.error("Failed to cancel scheduled notification")
|
||||
} finally {
|
||||
setCancellingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const targetingSummary = (job: ScheduledNotification) => {
|
||||
if (job.target_role) return `Role: ${job.target_role}`
|
||||
if (job.target_user_ids?.length) {
|
||||
return `${job.target_user_ids.length} user(s)`
|
||||
}
|
||||
if (job.target_raw?.phones?.length) {
|
||||
return `${job.target_raw.phones.length} phone(s)`
|
||||
}
|
||||
if (job.target_raw?.emails?.length) {
|
||||
return `${job.target_raw.emails.length} email(s)`
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-grayScale-500">Notifications</p>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight text-grayScale-800">
|
||||
<CalendarClock className="h-6 w-6 text-brand-500" />
|
||||
Scheduled notifications
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
|
||||
View pending and completed bulk notification jobs. Cancel jobs before they are sent.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button className="shrink-0 bg-brand-500 text-white hover:bg-brand-600" asChild>
|
||||
<Link to="/notifications/create">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Send notification
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="shrink-0" disabled={loading} onClick={() => void load()}>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-grayScale-100 shadow-none">
|
||||
<CardContent className="space-y-4 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
||||
className="sm:max-w-[180px]"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value || "all"} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={channelFilter}
|
||||
onChange={(e) => setChannelFilter(e.target.value as typeof channelFilter)}
|
||||
className="sm:max-w-[180px]"
|
||||
>
|
||||
{CHANNEL_OPTIONS.map((opt) => (
|
||||
<option key={opt.value || "all"} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Badge variant="secondary" className="w-fit">
|
||||
{totalCount} job{totalCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16 text-sm text-grayScale-500">
|
||||
<SpinnerIcon className="mr-2 h-5 w-5" alt="" />
|
||||
Loading scheduled jobs…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-center text-sm text-destructive">
|
||||
Could not load scheduled notifications.
|
||||
<Button variant="outline" size="sm" className="ml-3" onClick={() => void load()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rows.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16 text-center">
|
||||
<Megaphone className="mx-auto mb-3 h-8 w-8 text-grayScale-300" />
|
||||
<p className="text-sm font-medium text-grayScale-600">No scheduled jobs found</p>
|
||||
<p className="mt-1 text-xs text-grayScale-400">
|
||||
Schedule a notification from the composer to see it here.
|
||||
</p>
|
||||
<Button className="mt-4" size="sm" asChild>
|
||||
<Link to="/notifications/create">Send notification</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-lg border border-grayScale-100">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Title / message</TableHead>
|
||||
<TableHead>Audience</TableHead>
|
||||
<TableHead>Scheduled</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell className="font-mono text-xs">#{job.id}</TableCell>
|
||||
<TableCell>{channelLabel(job.channel)}</TableCell>
|
||||
<TableCell className="max-w-[240px]">
|
||||
<p className="truncate text-sm font-medium text-grayScale-700">
|
||||
{job.title || "—"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-grayScale-400">{job.message}</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-grayScale-500">
|
||||
{targetingSummary(job)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-grayScale-500">
|
||||
{formatScheduledAtLabel(job.scheduled_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={scheduledStatusBadgeVariant(job.status)}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
{job.last_error && job.status === "failed" && (
|
||||
<p className="mt-1 max-w-[180px] truncate text-[10px] text-destructive">
|
||||
{job.last_error}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{(job.status === "pending" || job.status === "processing") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={cancellingId === job.id}
|
||||
onClick={() => void handleCancel(job)}
|
||||
>
|
||||
{cancellingId === job.id ? (
|
||||
<SpinnerIcon className="mr-1 h-3.5 w-3.5" alt="" />
|
||||
) : (
|
||||
<XCircle className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -633,6 +633,103 @@ export interface UpdateParentLinkedPracticeResponse {
|
|||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Question row in GET/PUT /practices/:id/full and /exam-prep/practices/:id/full. */
|
||||
export interface PracticeFullQuestionItem {
|
||||
id?: number | null
|
||||
display_order: number
|
||||
question_text?: string
|
||||
question_type: string
|
||||
question_type_definition_id?: number | null
|
||||
dynamic_payload?: DynamicQuestionPayload | null
|
||||
difficulty_level?: string
|
||||
points?: number
|
||||
status?: PracticePublishStatus | string
|
||||
options?: QuestionOption[]
|
||||
short_answers?: QuestionShortAnswer[] | string[]
|
||||
voice_prompt?: string
|
||||
sample_answer_voice_prompt?: string
|
||||
audio_correct_answer_text?: string
|
||||
image_url?: string
|
||||
tips?: string
|
||||
explanation?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface PracticeFullQuestionSet {
|
||||
id: number
|
||||
title: string
|
||||
description?: string | null
|
||||
set_type?: string
|
||||
owner_type?: string
|
||||
owner_id?: number
|
||||
persona?: string | null
|
||||
shuffle_questions?: boolean
|
||||
status?: PracticePublishStatus | string
|
||||
time_limit_minutes?: number | null
|
||||
passing_score?: number | null
|
||||
intro_video_url?: string | null
|
||||
question_count?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface PracticeFullPractice {
|
||||
id: number
|
||||
title: string
|
||||
story_description?: string
|
||||
story_image?: string
|
||||
persona_id?: number | null
|
||||
question_set_id: number
|
||||
publish_status?: PracticePublishStatus | string | null
|
||||
quick_tips?: string
|
||||
lesson_id?: number
|
||||
parent_kind?: string
|
||||
parent_id?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface PracticeFullData {
|
||||
practice: PracticeFullPractice
|
||||
question_set: PracticeFullQuestionSet
|
||||
questions: PracticeFullQuestionItem[]
|
||||
}
|
||||
|
||||
export interface GetPracticeFullResponse {
|
||||
message: string
|
||||
data: PracticeFullData
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
export interface UpdatePracticeFullRequest {
|
||||
practice: {
|
||||
title: string
|
||||
story_description: string
|
||||
story_image: string
|
||||
persona_id: number
|
||||
quick_tips: string
|
||||
publish_status: PracticePublishStatus
|
||||
}
|
||||
question_set: {
|
||||
title: string
|
||||
description?: string | null
|
||||
time_limit_minutes?: number | null
|
||||
passing_score?: number | null
|
||||
shuffle_questions: boolean
|
||||
status: PracticePublishStatus
|
||||
intro_video_url?: string | null
|
||||
}
|
||||
questions: PracticeFullQuestionItem[]
|
||||
}
|
||||
|
||||
export interface UpdatePracticeFullResponse {
|
||||
message: string
|
||||
data: PracticeFullData
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Body for PUT /lessons/:id (Learn English top-level module lessons). */
|
||||
export interface UpdateTopLevelModuleLessonRequest {
|
||||
title: string
|
||||
|
|
|
|||
|
|
@ -55,3 +55,92 @@ export interface GetNotificationsResponse {
|
|||
export interface UnreadCountResponse {
|
||||
unread: number
|
||||
}
|
||||
|
||||
export type NotificationChannel = "sms" | "email" | "push" | "in_app"
|
||||
|
||||
export type ScheduledNotificationStatus =
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "sent"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
|
||||
export type InAppNotificationLevel = "info" | "warning" | "error" | "success"
|
||||
|
||||
export type PlatformRole =
|
||||
| "STUDENT"
|
||||
| "OPEN_LEARNER"
|
||||
| "INSTRUCTOR"
|
||||
| "ADMIN"
|
||||
| "SUPER_ADMIN"
|
||||
| "SUPPORT"
|
||||
|
||||
export interface BulkSendResult {
|
||||
total_recipients?: number
|
||||
sent: number
|
||||
failed: number
|
||||
target_users?: number
|
||||
image?: string
|
||||
}
|
||||
|
||||
export interface ScheduledNotificationTargetRaw {
|
||||
phones?: string[]
|
||||
emails?: string[]
|
||||
type?: string
|
||||
level?: string
|
||||
}
|
||||
|
||||
export interface ScheduledNotification {
|
||||
id: number
|
||||
channel: NotificationChannel
|
||||
title?: string
|
||||
message: string
|
||||
html?: string
|
||||
scheduled_at: string
|
||||
status: ScheduledNotificationStatus
|
||||
target_user_ids?: number[]
|
||||
target_role?: string
|
||||
target_raw?: ScheduledNotificationTargetRaw
|
||||
attempt_count?: number
|
||||
last_error?: string
|
||||
processing_started_at?: string | null
|
||||
sent_at?: string | null
|
||||
cancelled_at?: string | null
|
||||
created_by?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ListScheduledNotificationsResponse {
|
||||
scheduled_notifications: ScheduledNotification[]
|
||||
total_count: number
|
||||
limit: number
|
||||
page: number
|
||||
}
|
||||
|
||||
export interface BulkSmsRequest {
|
||||
message: string
|
||||
user_ids?: number[]
|
||||
role?: string
|
||||
phone_numbers?: string[]
|
||||
scheduled_at?: string
|
||||
}
|
||||
|
||||
export interface BulkInAppRequest {
|
||||
title: string
|
||||
message: string
|
||||
user_ids?: number[]
|
||||
role?: string
|
||||
scheduled_at?: string
|
||||
type?: string
|
||||
level?: InAppNotificationLevel
|
||||
}
|
||||
|
||||
export interface GetScheduledNotificationsParams {
|
||||
status?: ScheduledNotificationStatus
|
||||
channel?: NotificationChannel
|
||||
after?: string
|
||||
before?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user