From 035d73889e8bb5be5b994d0da6d8e124d3f133ab Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 12 Jun 2026 05:26:35 -0700 Subject: [PATCH] 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 --- src/api/courses.api.ts | 28 + src/api/notifications.api.ts | 133 ++- src/app/AppRoutes.tsx | 22 + .../NotificationSchedulePicker.tsx | 173 ++++ src/components/sidebar/Sidebar.tsx | 1 + src/layouts/AppLayout.tsx | 11 +- src/lib/learnEnglishDefinitionQuestion.ts | 1 + src/lib/notificationBulk.ts | 173 ++++ src/lib/practiceCreationOrchestrator.ts | 21 +- src/lib/practiceEditOrchestrator.ts | 46 + src/lib/practiceFullMapper.ts | 711 +++++++++++++++ .../content-management/AddPracticeFlow.tsx | 10 +- .../content-management/CourseDetailPage.tsx | 4 +- .../content-management/EditPracticeFlow.tsx | 646 ++++++++++++++ .../LessonPracticesPage.tsx | 28 +- .../content-management/ModuleDetailPage.tsx | 2 +- .../PracticeSequentialReview.tsx | 10 +- .../practice-steps/QuestionsStep.tsx | 469 ++++++++-- .../components/practice-steps/ReviewStep.tsx | 6 + .../notifications/CreateNotificationPage.tsx | 823 +++++++++++++----- src/pages/notifications/NotificationsPage.tsx | 659 +------------- .../ScheduledNotificationsPage.tsx | 318 +++++++ src/types/course.types.ts | 97 +++ src/types/notification.types.ts | 89 ++ 24 files changed, 3492 insertions(+), 989 deletions(-) create mode 100644 src/components/notifications/NotificationSchedulePicker.tsx create mode 100644 src/lib/notificationBulk.ts create mode 100644 src/lib/practiceEditOrchestrator.ts create mode 100644 src/lib/practiceFullMapper.ts create mode 100644 src/pages/content-management/EditPracticeFlow.tsx create mode 100644 src/pages/notifications/ScheduledNotificationsPage.tsx diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 86bebf5..0c58c59 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -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(`/practices/${practiceId}`, data) +/** GET /practices/:id/full — Learn English practice with question set and questions. */ +export const getLearnEnglishPracticeFull = (practiceId: number) => + http.get(`/practices/${practiceId}/full`) + +/** PUT /practices/:id/full — atomic update of practice, question set, and questions. */ +export const updateLearnEnglishPracticeFull = ( + practiceId: number, + data: UpdatePracticeFullRequest, +) => + http.put(`/practices/${practiceId}/full`, data) + +/** GET /exam-prep/practices/:id/full */ +export const getExamPrepPracticeFull = (practiceId: number) => + http.get(`/exam-prep/practices/${practiceId}/full`) + +/** PUT /exam-prep/practices/:id/full */ +export const updateExamPrepPracticeFull = ( + practiceId: number, + data: UpdatePracticeFullRequest, +) => + http.put( + `/exam-prep/practices/${practiceId}/full`, + data, + ) + /** PUT /practices/:id — set publish_status only (Learn English practice). */ export const setLearnEnglishPracticePublishStatus = ( practiceId: number, diff --git a/src/api/notifications.api.ts b/src/api/notifications.api.ts index 4eaeabc..6b0074c 100644 --- a/src/api/notifications.api.ts +++ b/src/api/notifications.api.ts @@ -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 { 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 => { + const res = await http.post("/notifications/bulk-sms", data) + return parseBulkSendApiResponse(res.data, res.status) +} + +export const sendBulkEmail = async (formData: FormData): Promise => { + 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 => { + 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 => { + 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("/notifications/scheduled", { params }) + .then((res) => ({ ...res, data: parseScheduledList(res.data) })) + +export const getScheduledNotificationById = (id: number) => + http.get(`/notifications/scheduled/${id}`).then((res) => ({ + ...res, + data: normalizeScheduledNotification(unwrapEnvelopeData(res.data)), + })) + +export const cancelScheduledNotification = (id: number) => + http.post(`/notifications/scheduled/${id}/cancel`).then((res) => ({ + ...res, + data: normalizeScheduledNotification(unwrapEnvelopeData(res.data)), + })) diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index fb38155..5aee9fb 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -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={} /> + } + /> } @@ -240,6 +246,18 @@ export function AppRoutes() { path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/practices" element={} /> + } + /> + } + /> + } + /> } @@ -262,6 +280,10 @@ export function AppRoutes() { path="/notifications/create" element={} /> + } + /> } /> } /> } /> diff --git a/src/components/notifications/NotificationSchedulePicker.tsx b/src/components/notifications/NotificationSchedulePicker.tsx new file mode 100644 index 0000000..d083e22 --- /dev/null +++ b/src/components/notifications/NotificationSchedulePicker.tsx @@ -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 ( + + + + + +

Schedule notification

+
+
+ +
+ setYear(digitsOnly(e.target.value, 4))} + inputMode="numeric" + maxLength={4} + className="h-9 rounded-lg border-grayScale-200 bg-white text-center text-sm" + /> + - + 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" + /> + - + 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" + /> +
+
+
+ +
+ 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" + /> + : + 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" + /> +
+
+
+
+
+ + +
+ +
+
+
+ ) +} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index f91e488..781e2d0 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -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" }, ], }, diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index ce84565..8c67aa5 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -60,7 +60,7 @@ export function AppLayout() { } return ( -
+
-
+
-