This commit is contained in:
“kirukib” 2026-02-27 19:31:41 +03:00
parent e35defe48a
commit cd2ed66960
9 changed files with 979 additions and 148 deletions

View File

@ -37,11 +37,15 @@ import type {
CreateQuestionRequest,
CreateQuestionResponse,
CreateVimeoVideoRequest,
CreateCourseCategoryRequest,
} from "../types/course.types"
export const getCourseCategories = () =>
http.get<GetCourseCategoriesResponse>("/course-management/categories")
export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
http.post("/course-management/categories", data)
export const getCoursesByCategory = (categoryId: number) =>
http.get<GetCoursesResponse>(`/course-management/categories/${categoryId}/courses`)

View File

@ -14,3 +14,17 @@ export const getUserById = (id: number) =>
export const getMyProfile = () =>
http.get<UserProfileResponse>("/team/me");
// Best-guess API for creating a new user (admin-side).
// Adjust payload shape or endpoint if backend differs.
export interface CreateUserRequest {
first_name: string;
last_name: string;
email: string;
phone_number: string;
role: string;
notes?: string;
}
export const createUser = (payload: CreateUserRequest) =>
http.post("/users", payload);

View File

@ -226,7 +226,7 @@ export function ProfilePage() {
</div>
<div className="px-6 py-6 sm:px-8 sm:py-7">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1.8fr)_minmax(0,1.2fr)]">
<div className="grid gap-8 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)]">
{/* Left column: About & details */}
<div className="space-y-6">
{/* Identity */}
@ -348,70 +348,6 @@ export function ProfilePage() {
</div>
</div>
{/* Middle column: Job information */}
<div className="space-y-6">
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Job information
</h3>
<div className="overflow-x-auto rounded-xl border border-grayScale-100">
<table className="w-full min-w-[600px] border-collapse text-sm">
<thead className="bg-grayScale-50 text-xs font-medium uppercase tracking-[0.12em] text-grayScale-400">
<tr>
<th className="px-4 py-2 text-left">Title</th>
<th className="px-4 py-2 text-left">Team</th>
<th className="px-4 py-2 text-left">Division</th>
<th className="px-4 py-2 text-left">Manager</th>
<th className="px-4 py-2 text-left">Hire date</th>
<th className="px-4 py-2 text-left">Location</th>
</tr>
</thead>
<tbody className="divide-y divide-grayScale-100 text-grayScale-700">
<tr>
<td className="px-4 py-3">{profile.occupation || profile.role}</td>
<td className="px-4 py-3">{profile.role}</td>
<td className="px-4 py-3">{profile.preferred_language || "—"}</td>
<td className="px-4 py-3"></td>
<td className="px-4 py-3">{formatDate(profile.created_at)}</td>
<td className="px-4 py-3">
{[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Learning & goals */}
<div className="grid gap-4 md:grid-cols-2">
<Card className="shadow-none border-grayScale-100">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-grayScale-700">
Learning goal
</CardTitle>
</CardHeader>
<CardContent className="pt-1">
<p className="text-sm text-grayScale-500">
{profile.learning_goal || "No learning goal specified."}
</p>
</CardContent>
</Card>
<Card className="shadow-none border-grayScale-100">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-grayScale-700">
Language goal
</CardTitle>
</CardHeader>
<CardContent className="pt-1">
<p className="text-sm text-grayScale-500">
{profile.language_goal || "No language goal specified."}
</p>
</CardContent>
</Card>
</div>
</div>
{/* Right column: Activity & account summary */}
<div className="space-y-6">
{/* Activity */}

View File

@ -1,6 +1,7 @@
import { useState } from "react"
import { useNavigate, useParams } from "react-router-dom"
import { ArrowLeft, Plus, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
@ -105,32 +106,44 @@ export function AddQuestionPage() {
// Validation
if (!formData.question.trim()) {
alert("Please enter a question")
toast.error("Missing question", {
description: "Please enter a question before saving.",
})
return
}
if (formData.type === "multiple-choice" || formData.type === "true-false") {
if (!formData.correctAnswer) {
alert("Please select a correct answer")
toast.error("Missing correct answer", {
description: "Select the correct answer for this question.",
})
return
}
if (formData.type === "multiple-choice") {
const hasEmptyOptions = formData.options.some((opt) => !opt.trim())
if (hasEmptyOptions) {
alert("Please fill in all options")
toast.error("Incomplete options", {
description: "Fill in all answer options for this multiple choice question.",
})
return
}
}
} else if (formData.type === "short-answer") {
if (!formData.correctAnswer.trim()) {
alert("Please enter a correct answer")
toast.error("Missing correct answer", {
description: "Enter the expected correct answer.",
})
return
}
}
// In a real app, save the question here
console.log("Saving question:", formData)
alert(isEditing ? "Question updated successfully!" : "Question created successfully!")
toast.success(isEditing ? "Question updated" : "Question created", {
description: isEditing
? "The question has been updated successfully."
: "Your new question has been created.",
})
navigate("/content/questions")
}

View File

@ -1,14 +1,27 @@
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { FolderOpen, RefreshCw, AlertCircle, BookOpen } from "lucide-react"
import { FolderOpen, RefreshCw, AlertCircle, BookOpen, Plus } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { getCourseCategories } from "../../api/courses.api"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { getCourseCategories, createCourseCategory } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types"
import { toast } from "sonner"
export function CourseCategoryPage() {
const [categories, setCategories] = useState<CourseCategory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [newCategoryName, setNewCategoryName] = useState("")
const [creating, setCreating] = useState(false)
const fetchCategories = async () => {
setLoading(true)
@ -68,11 +81,21 @@ export function CourseCategoryPage() {
return (
<div className="space-y-8">
{/* Page header */}
<div>
<h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1>
<p className="mt-1 text-sm text-grayScale-400">
Browse and manage your course categories below
</p>
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1>
<p className="mt-1 text-sm text-grayScale-400">
Browse and manage your course categories below
</p>
</div>
<Button
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
size="sm"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
New Category
</Button>
</div>
{categories.length === 0 ? (
@ -126,6 +149,75 @@ export function CourseCategoryPage() {
))}
</div>
)}
{/* Create category dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-4 w-4 text-brand-500" />
<span>Create course category</span>
</DialogTitle>
<DialogDescription>
Add a new high-level bucket to organize your courses.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Category name
</label>
<Input
placeholder="e.g. Beginner English, Exam Prep"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setCreateOpen(false)
setNewCategoryName("")
}}
disabled={creating}
>
Cancel
</Button>
<Button
className="bg-brand-500 text-white hover:bg-brand-600"
disabled={creating || !newCategoryName.trim()}
onClick={async () => {
if (!newCategoryName.trim()) return
setCreating(true)
try {
await createCourseCategory({ name: newCategoryName.trim() })
toast.success("Category created", {
description: `"${newCategoryName.trim()}" has been added.`,
})
setNewCategoryName("")
setCreateOpen(false)
fetchCategories()
} catch (err: any) {
const message =
err?.response?.data?.message ||
"Failed to create category. Please try again."
toast.error("Could not create category", {
description: message,
})
} finally {
setCreating(false)
}
}}
>
{creating ? "Creating..." : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -20,6 +20,7 @@ import {
CheckCircle2,
XCircle,
ArrowUpCircle,
MessageCircle,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
@ -201,6 +202,12 @@ export function IssuesPage() {
// Status update
const [statusUpdating, setStatusUpdating] = useState<number | null>(null);
// Create issue dialog (admin-created)
const [createOpen, setCreateOpen] = useState(false);
const [createSubject, setCreateSubject] = useState("");
const [createType, setCreateType] = useState<string>("bug");
const [createDescription, setCreateDescription] = useState("");
const fetchIssues = useCallback(async () => {
setLoading(true);
try {
@ -345,17 +352,26 @@ export function IssuesPage() {
Review and manage user-reported issues across the platform.
</p>
</div>
<Button
variant="outline"
className="gap-2"
onClick={() => {
setPage(1);
fetchIssues();
}}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="gap-2"
onClick={() => {
setPage(1);
fetchIssues();
}}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
<Button
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
onClick={() => setCreateOpen(true)}
>
<MessageCircle className="h-4 w-4" />
New Issue
</Button>
</div>
</div>
{/* Stats cards */}
@ -840,6 +856,90 @@ export function IssuesPage() {
</DialogContent>
</Dialog>
{/* Create Issue Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageCircle className="h-5 w-5 text-brand-500" />
<span>Create admin issue</span>
</DialogTitle>
<DialogDescription>
Log an issue directly from the admin panel so it can be tracked and resolved.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Subject
</label>
<Input
placeholder="Short summary of the issue"
value={createSubject}
onChange={(e) => setCreateSubject(e.target.value)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Type
</label>
<select
value={createType}
onChange={(e) => setCreateType(e.target.value)}
className="h-10 w-full rounded-lg border bg-white px-3 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
>
{ISSUE_TYPES.map((t) => (
<option key={t} value={t}>
{getIssueTypeConfig(t).label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Description
</label>
<textarea
className="min-h-[100px] w-full rounded-lg border bg-white px-3 py-2 text-sm text-grayScale-700 placeholder:text-grayScale-400 focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Describe what happened, steps to reproduce, and any context that might help."
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setCreateOpen(false);
setCreateSubject("");
setCreateDescription("");
setCreateType("bug");
}}
>
Cancel
</Button>
<Button
className="bg-brand-500 text-white hover:bg-brand-600"
onClick={() => {
// Hook to create-issue API here; currently UI-only.
if (!createSubject.trim() || !createDescription.trim()) {
return;
}
setCreateOpen(false);
setCreateSubject("");
setCreateDescription("");
setCreateType("bug");
}}
>
Create Issue
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-sm">

View File

@ -19,10 +19,22 @@ import {
Mail,
CheckCheck,
MailX,
Search,
} 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 { cn } from "../../lib/utils"
import {
getNotifications,
@ -32,7 +44,9 @@ import {
markAllRead,
markAllUnread,
} from "../../api/notifications.api"
import { getTeamMembers } from "../../api/team.api"
import type { Notification } from "../../types/notification.types"
import type { TeamMember } from "../../types/team.types"
const PAGE_SIZE = 10
@ -226,6 +240,22 @@ export function NotificationsPage() {
const [error, setError] = useState(false)
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
const [bulkLoading, setBulkLoading] = useState(false)
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
const [searchTerm, setSearchTerm] = useState("")
const [composeChannels, setComposeChannels] = useState<Array<"push" | "sms">>(["push"])
const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all")
const [teamRecipients, setTeamRecipients] = useState<TeamMember[]>([])
const [recipientsLoading, setRecipientsLoading] = useState(false)
const [selectedRecipientIds, setSelectedRecipientIds] = useState<number[]>([])
const [composeTitle, setComposeTitle] = useState("")
const [composeMessage, setComposeMessage] = useState("")
const [sending, setSending] = useState(false)
const [composeOpen, setComposeOpen] = useState(false)
const fetchData = useCallback(async (currentOffset: number) => {
setLoading(true)
@ -300,59 +330,175 @@ export function NotificationsPage() {
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
const filteredNotifications = notifications.filter((n) => {
if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false
if (activeStatusTab === "read" && !n.is_read) return false
if (activeStatusTab === "unread" && n.is_read) return false
if (searchTerm.trim()) {
const q = searchTerm.toLowerCase()
const haystack = [
n.payload.headline,
n.payload.message,
formatTypeLabel(n.type),
n.delivery_channel,
n.level,
]
.filter(Boolean)
.join(" ")
.toLowerCase()
if (!haystack.includes(q)) return false
}
return true
})
const handleOpenDetail = (notification: Notification) => {
setSelectedNotification(notification)
setDetailOpen(true)
}
const handleComposeSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!composeTitle.trim() || !composeMessage.trim()) return
if (composeChannels.length === 0) return
setSending(true)
try {
// Hook up to backend send API here when available.
// For now, we just reset the form after a short delay for UI feedback.
await new Promise((resolve) => setTimeout(resolve, 400))
setComposeTitle("")
setComposeMessage("")
setComposeAudience("all")
setComposeChannels(["push"])
setSelectedRecipientIds([])
setComposeOpen(false)
} finally {
setSending(false)
}
}
// Lazy-load users for recipient selection when compose dialog first opens
useEffect(() => {
if (!composeOpen || teamRecipients.length > 0 || recipientsLoading) return
setRecipientsLoading(true)
getTeamMembers(1, 50)
.then((res) => {
setTeamRecipients(res.data.data ?? [])
})
.catch(() => {
setTeamRecipients([])
})
.finally(() => {
setRecipientsLoading(false)
})
}, [composeOpen, teamRecipients.length, recipientsLoading])
return (
<div className="mx-auto w-full max-w-3xl">
<div className="mx-auto w-full max-w-6xl">
{/* Header */}
<div className="mb-5">
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
{totalCount > 0 && (
<Badge variant="secondary">{totalCount}</Badge>
)}
{globalUnread > 0 && (
<Badge variant="default">{globalUnread} unread</Badge>
)}
{totalCount > 0 && <Badge variant="secondary">{totalCount}</Badge>}
{globalUnread > 0 && <Badge variant="default">{globalUnread} unread</Badge>}
</div>
{/* Bulk actions */}
{!loading && !error && notifications.length > 0 && (
{!loading && !error && (
<div className="flex items-center gap-2">
{globalUnread > 0 ? (
<Button
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllRead}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
<Button
size="sm"
className="bg-brand-500 text-white hover:bg-brand-600"
onClick={() => setComposeOpen(true)}
>
<Megaphone className="mr-2 h-3.5 w-3.5" />
New notification
</Button>
{notifications.length > 0 && (
<>
{globalUnread > 0 ? (
<Button
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllRead}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<CheckCheck className="mr-2 h-3.5 w-3.5" />
)}
Mark all read
</Button>
) : (
<CheckCheck className="mr-2 h-3.5 w-3.5" />
<Button
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllUnread}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<MailX className="mr-2 h-3.5 w-3.5" />
)}
Mark all unread
</Button>
)}
Mark all read
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllUnread}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<MailX className="mr-2 h-3.5 w-3.5" />
)}
Mark all unread
</Button>
</>
)}
</div>
)}
</div>
</div>
{/* Summary cards */}
{!loading && !error && (
<div className="mb-5 grid gap-4 sm:grid-cols-3">
<Card className="shadow-none border border-grayScale-100">
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-xs font-medium text-grayScale-500">Total notifications</p>
<p className="mt-1 text-xl font-semibold text-grayScale-700">
{totalCount.toLocaleString()}
</p>
</div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-brand-500 text-white">
<Bell className="h-5 w-5" />
</div>
</CardContent>
</Card>
<Card className="shadow-none border border-grayScale-100">
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-xs font-medium text-grayScale-500">Unread</p>
<p className="mt-1 text-xl font-semibold text-grayScale-700">
{globalUnread.toLocaleString()}
</p>
</div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-amber-50 text-amber-600">
<BellOff className="h-5 w-5" />
</div>
</CardContent>
</Card>
<Card className="shadow-none border border-grayScale-100">
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-xs font-medium text-grayScale-500">Channels used</p>
<p className="mt-1 text-xl font-semibold text-grayScale-700">
{Array.from(new Set(notifications.map((n) => n.delivery_channel))).length || "—"}
</p>
</div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500">
<MailOpen className="h-5 w-5" />
</div>
</CardContent>
</Card>
</div>
)}
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-20">
@ -381,26 +527,190 @@ export function NotificationsPage() {
<BellOff className="h-7 w-7 text-grayScale-400" />
</div>
<span className="text-sm font-medium text-grayScale-500">No notifications yet</span>
<span className="text-xs text-grayScale-400">When you receive notifications, they'll appear here.</span>
<span className="text-xs text-grayScale-400">
When you receive notifications, they'll appear here.
</span>
</CardContent>
</Card>
)}
{/* Notification list */}
{/* Filters + table */}
{!loading && !error && notifications.length > 0 && (
<>
<Card className="shadow-none">
<CardContent className="divide-y-0 p-2">
<div className="space-y-1">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onToggleRead={handleToggleRead}
toggling={togglingIds.has(n.id)}
/>
))}
{/* Status tabs */}
<div className="mb-2 border-b border-grayScale-200">
<div className="-mb-px flex gap-6">
{(["all", "unread", "read"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveStatusTab(tab)}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeStatusTab === tab
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700",
)}
>
{tab === "all" ? "All" : tab === "unread" ? "Unread" : "Read"}
{activeStatusTab === tab && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
))}
</div>
</div>
{/* Filters */}
<Card className="mb-3 shadow-none">
<CardContent className="flex flex-wrap items-center gap-3 p-4">
<div className="relative flex-1 min-w-[180px] max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
placeholder="Search by title, message, or type…"
className="pl-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Channel</span>
<Select
value={channelFilter}
onChange={(e) => setChannelFilter(e.target.value as typeof channelFilter)}
className="h-8 w-[130px] text-xs"
>
<option value="all">All</option>
<option value="push">Push</option>
<option value="sms">SMS</option>
</Select>
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Title</TableHead>
<TableHead className="hidden lg:table-cell">Message</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden sm:table-cell">Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredNotifications.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-sm text-grayScale-400">
No notifications match your filters.
</TableCell>
</TableRow>
) : (
filteredNotifications.map((n) => {
const config = TYPE_CONFIG[n.type] ?? DEFAULT_TYPE_CONFIG
const Icon = config.icon
const isToggling = togglingIds.has(n.id)
return (
<TableRow
key={n.id}
className={cn(
"cursor-pointer",
!n.is_read && "bg-brand-50/40 hover:bg-brand-50/70",
)}
onClick={() => handleOpenDetail(n)}
>
<TableCell>
<div className="flex items-center gap-2">
<div
className={cn(
"grid h-8 w-8 place-items-center rounded-lg text-xs",
config.bg,
config.color,
)}
>
<Icon className="h-4 w-4" />
</div>
<span className="text-xs font-medium text-grayScale-600">
{formatTypeLabel(n.type)}
</span>
</div>
</TableCell>
<TableCell>
<p
className={cn(
"max-w-xs truncate text-sm font-medium",
n.is_read ? "text-grayScale-600" : "text-grayScale-800",
)}
>
{n.payload.headline}
</p>
</TableCell>
<TableCell className="hidden lg:table-cell">
<p className="max-w-sm truncate text-xs text-grayScale-500">
{n.payload.message}
</p>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px] capitalize">
{n.delivery_channel}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={getLevelBadge(n.level)}
className="text-[10px] uppercase tracking-wide"
>
{n.is_read ? "Read" : "Unread"}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-xs text-grayScale-400">
{formatTimestamp(n.timestamp)}
</span>
</TableCell>
<TableCell className="text-right">
<div
className="flex items-center justify-end gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={isToggling}
onClick={() => handleToggleRead(n.id, n.is_read)}
title={n.is_read ? "Mark as unread" : "Mark as read"}
>
{isToggling ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-grayScale-400" />
) : n.is_read ? (
<Mail className="h-3.5 w-3.5 text-grayScale-400" />
) : (
<MailOpen className="h-3.5 w-3.5 text-brand-500" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => handleOpenDetail(n)}
>
View
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
@ -435,6 +745,275 @@ export function NotificationsPage() {
)}
</>
)}
{/* Detail dialog */}
{selectedNotification && (
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
{(() => {
const Icon =
(TYPE_CONFIG[selectedNotification.type] ?? DEFAULT_TYPE_CONFIG).icon
return <Icon className="h-4 w-4" />
})()}
</span>
<span className="truncate text-base">
{selectedNotification.payload.headline}
</span>
</DialogTitle>
<DialogDescription>
Sent via {selectedNotification.delivery_channel} ·{" "}
{formatTimestamp(selectedNotification.timestamp)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-sm text-grayScale-600">
{selectedNotification.payload.message}
</p>
</div>
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
<div>
<p className="text-grayScale-400">Type</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatTypeLabel(selectedNotification.type)}
</p>
</div>
<div>
<p className="text-grayScale-400">Level</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{selectedNotification.level}
</p>
</div>
<div>
<p className="text-grayScale-400">Channel</p>
<p className="mt-0.5 font-medium text-grayScale-700 capitalize">
{selectedNotification.delivery_channel}
</p>
</div>
<div>
<p className="text-grayScale-400">Delivery status</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{selectedNotification.delivery_status}
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* Compose dialog */}
<Dialog open={composeOpen} onOpenChange={setComposeOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-brand-500" />
<span>Create notification</span>
</DialogTitle>
<DialogDescription>
Send a one-off push or SMS notification to your users.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleComposeSubmit} className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)]">
<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-white text-brand-600 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-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
<Mail className="h-3.5 w-3.5" />
SMS
</button>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Audience
</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")}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
composeAudience === "all"
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
All users
</button>
<button
type="button"
onClick={() => setComposeAudience("selected")}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
composeAudience === "selected"
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
Selected users
</button>
</div>
</div>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">Title</label>
<Input
placeholder="Short headline for this notification"
value={composeTitle}
onChange={(e) => setComposeTitle(e.target.value)}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">Message</label>
<Textarea
rows={3}
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."
}
value={composeMessage}
onChange={(e) => setComposeMessage(e.target.value)}
/>
</div>
</div>
{composeAudience === "selected" && (
<div className="space-y-2">
<p className="text-xs font-medium text-grayScale-500">Recipients</p>
<div className="max-h-48 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">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
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) => (
<label
key={member.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-grayScale-100"
>
<input
type="checkbox"
className="h-3.5 w-3.5 rounded border-grayScale-300"
checked={selectedRecipientIds.includes(member.id)}
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>
))}
</div>
<p className="text-[11px] text-grayScale-400">
Only the selected users will receive this notification.
</p>
</div>
)}
<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.
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setComposeTitle("")
setComposeMessage("")
setComposeAudience("all")
setComposeChannels(["push"])
setSelectedRecipientIds([])
}}
>
Clear
</Button>
<Button
type="submit"
size="sm"
disabled={sending || !composeTitle.trim() || !composeMessage.trim()}
>
{sending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending
</>
) : (
<>
<MailOpen className="mr-2 h-3.5 w-3.5" />
Send notification
</>
)}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -1,14 +1,62 @@
import { useState } from "react"
import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { createUser } from "../../api/users.api"
export function RegisterUserPage() {
const navigate = useNavigate()
const [firstName, setFirstName] = useState("")
const [lastName, setLastName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [role, setRole] = useState("")
const [notes, setNotes] = useState("")
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!firstName.trim() || !lastName.trim() || !email.trim() || !phone.trim() || !role) {
toast.error("Missing required fields", {
description: "Please fill in first name, last name, email, phone, and role.",
})
return
}
setSubmitting(true)
try {
await createUser({
first_name: firstName.trim(),
last_name: lastName.trim(),
email: email.trim(),
phone_number: phone.trim(),
role: role.toUpperCase(),
notes: notes.trim() || undefined,
})
toast.success("User registered", {
description: `${firstName} ${lastName} has been created successfully.`,
})
navigate("/users")
} catch (error: any) {
const message =
error?.response?.data?.message ||
"Failed to register user. Please check the details and try again."
toast.error("Registration failed", {
description: message,
})
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
@ -22,21 +70,31 @@ export function RegisterUserPage() {
</div>
<Card className="mx-auto max-w-2xl p-6">
<form className="space-y-5">
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" />
First Name
</label>
<Input placeholder="Enter first name" required />
<Input
placeholder="Enter first name"
required
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" />
Last Name
</label>
<Input placeholder="Enter last name" required />
<Input
placeholder="Enter last name"
required
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
</div>
@ -45,7 +103,13 @@ export function RegisterUserPage() {
<Mail className="h-4 w-4" />
Email
</label>
<Input type="email" placeholder="Enter email address" required />
<Input
type="email"
placeholder="Enter email address"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
@ -53,7 +117,13 @@ export function RegisterUserPage() {
<Phone className="h-4 w-4" />
Phone
</label>
<Input type="tel" placeholder="Enter phone number" required />
<Input
type="tel"
placeholder="Enter phone number"
required
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div>
@ -61,10 +131,14 @@ export function RegisterUserPage() {
<Shield className="h-4 w-4" />
Role
</label>
<Select required>
<Select
required
value={role}
onChange={(e) => setRole(e.target.value)}
>
<option value="">Select role</option>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="ADMIN">Admin</option>
<option value="USER">User</option>
</Select>
</div>
@ -73,15 +147,30 @@ export function RegisterUserPage() {
<FileText className="h-4 w-4" />
Notes
</label>
<Textarea placeholder="Enter any additional notes" rows={3} />
<Textarea
placeholder="Enter any additional notes"
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => navigate("/users")}>
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => navigate("/users")}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
Register User
<Button
type="submit"
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto"
disabled={submitting}
>
{submitting ? "Registering..." : "Register User"}
</Button>
</div>
</form>

View File

@ -5,6 +5,10 @@ export interface CourseCategory {
created_at: string
}
export interface CreateCourseCategoryRequest {
name: string
}
export interface GetCourseCategoriesResponse {
message: string
data: {