ui+plus
This commit is contained in:
parent
e35defe48a
commit
cd2ed66960
|
|
@ -37,11 +37,15 @@ import type {
|
||||||
CreateQuestionRequest,
|
CreateQuestionRequest,
|
||||||
CreateQuestionResponse,
|
CreateQuestionResponse,
|
||||||
CreateVimeoVideoRequest,
|
CreateVimeoVideoRequest,
|
||||||
|
CreateCourseCategoryRequest,
|
||||||
} from "../types/course.types"
|
} from "../types/course.types"
|
||||||
|
|
||||||
export const getCourseCategories = () =>
|
export const getCourseCategories = () =>
|
||||||
http.get<GetCourseCategoriesResponse>("/course-management/categories")
|
http.get<GetCourseCategoriesResponse>("/course-management/categories")
|
||||||
|
|
||||||
|
export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
|
||||||
|
http.post("/course-management/categories", data)
|
||||||
|
|
||||||
export const getCoursesByCategory = (categoryId: number) =>
|
export const getCoursesByCategory = (categoryId: number) =>
|
||||||
http.get<GetCoursesResponse>(`/course-management/categories/${categoryId}/courses`)
|
http.get<GetCoursesResponse>(`/course-management/categories/${categoryId}/courses`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,17 @@ export const getUserById = (id: number) =>
|
||||||
|
|
||||||
export const getMyProfile = () =>
|
export const getMyProfile = () =>
|
||||||
http.get<UserProfileResponse>("/team/me");
|
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);
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ export function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-6 sm:px-8 sm:py-7">
|
<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 */}
|
{/* Left column: About & details */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Identity */}
|
{/* Identity */}
|
||||||
|
|
@ -348,70 +348,6 @@ export function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Right column: Activity & account summary */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Activity */}
|
{/* Activity */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useNavigate, useParams } from "react-router-dom"
|
import { useNavigate, useParams } from "react-router-dom"
|
||||||
import { ArrowLeft, Plus, X } from "lucide-react"
|
import { ArrowLeft, Plus, X } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
|
@ -105,32 +106,44 @@ export function AddQuestionPage() {
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!formData.question.trim()) {
|
if (!formData.question.trim()) {
|
||||||
alert("Please enter a question")
|
toast.error("Missing question", {
|
||||||
|
description: "Please enter a question before saving.",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.type === "multiple-choice" || formData.type === "true-false") {
|
if (formData.type === "multiple-choice" || formData.type === "true-false") {
|
||||||
if (!formData.correctAnswer) {
|
if (!formData.correctAnswer) {
|
||||||
alert("Please select a correct answer")
|
toast.error("Missing correct answer", {
|
||||||
|
description: "Select the correct answer for this question.",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (formData.type === "multiple-choice") {
|
if (formData.type === "multiple-choice") {
|
||||||
const hasEmptyOptions = formData.options.some((opt) => !opt.trim())
|
const hasEmptyOptions = formData.options.some((opt) => !opt.trim())
|
||||||
if (hasEmptyOptions) {
|
if (hasEmptyOptions) {
|
||||||
alert("Please fill in all options")
|
toast.error("Incomplete options", {
|
||||||
|
description: "Fill in all answer options for this multiple choice question.",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (formData.type === "short-answer") {
|
} else if (formData.type === "short-answer") {
|
||||||
if (!formData.correctAnswer.trim()) {
|
if (!formData.correctAnswer.trim()) {
|
||||||
alert("Please enter a correct answer")
|
toast.error("Missing correct answer", {
|
||||||
|
description: "Enter the expected correct answer.",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In a real app, save the question here
|
// In a real app, save the question here
|
||||||
console.log("Saving question:", formData)
|
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")
|
navigate("/content/questions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,27 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
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 { 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 type { CourseCategory } from "../../types/course.types"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export function CourseCategoryPage() {
|
export function CourseCategoryPage() {
|
||||||
const [categories, setCategories] = useState<CourseCategory[]>([])
|
const [categories, setCategories] = useState<CourseCategory[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState("")
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -68,12 +81,22 @@ export function CourseCategoryPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1>
|
<h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1>
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
Browse and manage your course categories below
|
Browse and manage your course categories below
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? (
|
{categories.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-5 rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-24">
|
<div className="flex flex-col items-center justify-center gap-5 rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-24">
|
||||||
|
|
@ -126,6 +149,75 @@ export function CourseCategoryPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
ArrowUpCircle,
|
ArrowUpCircle,
|
||||||
|
MessageCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
|
|
@ -201,6 +202,12 @@ export function IssuesPage() {
|
||||||
// Status update
|
// Status update
|
||||||
const [statusUpdating, setStatusUpdating] = useState<number | null>(null);
|
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 () => {
|
const fetchIssues = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -345,6 +352,7 @@ export function IssuesPage() {
|
||||||
Review and manage user-reported issues across the platform.
|
Review and manage user-reported issues across the platform.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
|
|
@ -356,6 +364,14 @@ export function IssuesPage() {
|
||||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Stats cards */}
|
{/* Stats cards */}
|
||||||
|
|
@ -840,6 +856,90 @@ export function IssuesPage() {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,22 @@ import {
|
||||||
Mail,
|
Mail,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
MailX,
|
MailX,
|
||||||
|
Search,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
import { Button } from "../../components/ui/button"
|
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 { cn } from "../../lib/utils"
|
||||||
import {
|
import {
|
||||||
getNotifications,
|
getNotifications,
|
||||||
|
|
@ -32,7 +44,9 @@ import {
|
||||||
markAllRead,
|
markAllRead,
|
||||||
markAllUnread,
|
markAllUnread,
|
||||||
} from "../../api/notifications.api"
|
} from "../../api/notifications.api"
|
||||||
|
import { getTeamMembers } from "../../api/team.api"
|
||||||
import type { Notification } from "../../types/notification.types"
|
import type { Notification } from "../../types/notification.types"
|
||||||
|
import type { TeamMember } from "../../types/team.types"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
|
@ -226,6 +240,22 @@ export function NotificationsPage() {
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
|
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
|
||||||
const [bulkLoading, setBulkLoading] = useState(false)
|
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) => {
|
const fetchData = useCallback(async (currentOffset: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -300,25 +330,94 @@ export function NotificationsPage() {
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
|
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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-3xl">
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
|
||||||
{totalCount > 0 && (
|
{totalCount > 0 && <Badge variant="secondary">{totalCount}</Badge>}
|
||||||
<Badge variant="secondary">{totalCount}</Badge>
|
{globalUnread > 0 && <Badge variant="default">{globalUnread} unread</Badge>}
|
||||||
)}
|
|
||||||
{globalUnread > 0 && (
|
|
||||||
<Badge variant="default">{globalUnread} unread</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bulk actions */}
|
{/* Bulk actions */}
|
||||||
{!loading && !error && notifications.length > 0 && (
|
{!loading && !error && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<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 ? (
|
{globalUnread > 0 ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -348,11 +447,58 @@ export function NotificationsPage() {
|
||||||
Mark all unread
|
Mark all unread
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<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" />
|
<BellOff className="h-7 w-7 text-grayScale-400" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-grayScale-500">No notifications yet</span>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notification list */}
|
{/* Filters + table */}
|
||||||
{!loading && !error && notifications.length > 0 && (
|
{!loading && !error && notifications.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Card className="shadow-none">
|
{/* Status tabs */}
|
||||||
<CardContent className="divide-y-0 p-2">
|
<div className="mb-2 border-b border-grayScale-200">
|
||||||
<div className="space-y-1">
|
<div className="-mb-px flex gap-6">
|
||||||
{notifications.map((n) => (
|
{(["all", "unread", "read"] as const).map((tab) => (
|
||||||
<NotificationItem
|
<button
|
||||||
key={n.id}
|
key={tab}
|
||||||
notification={n}
|
type="button"
|
||||||
onToggleRead={handleToggleRead}
|
onClick={() => setActiveStatusTab(tab)}
|
||||||
toggling={togglingIds.has(n.id)}
|
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>
|
||||||
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,62 @@
|
||||||
|
import { useState } from "react"
|
||||||
import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react"
|
import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card } from "../../components/ui/card"
|
import { Card } from "../../components/ui/card"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import { Select } from "../../components/ui/select"
|
import { Select } from "../../components/ui/select"
|
||||||
|
import { createUser } from "../../api/users.api"
|
||||||
|
|
||||||
export function RegisterUserPage() {
|
export function RegisterUserPage() {
|
||||||
const navigate = useNavigate()
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -22,21 +70,31 @@ export function RegisterUserPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mx-auto max-w-2xl p-6">
|
<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 className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
First Name
|
First Name
|
||||||
</label>
|
</label>
|
||||||
<Input placeholder="Enter first name" required />
|
<Input
|
||||||
|
placeholder="Enter first name"
|
||||||
|
required
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
Last Name
|
Last Name
|
||||||
</label>
|
</label>
|
||||||
<Input placeholder="Enter last name" required />
|
<Input
|
||||||
|
placeholder="Enter last name"
|
||||||
|
required
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -45,7 +103,13 @@ export function RegisterUserPage() {
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
Email
|
Email
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -53,7 +117,13 @@ export function RegisterUserPage() {
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-4 w-4" />
|
||||||
Phone
|
Phone
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -61,10 +131,14 @@ export function RegisterUserPage() {
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
Role
|
Role
|
||||||
</label>
|
</label>
|
||||||
<Select required>
|
<Select
|
||||||
|
required
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value)}
|
||||||
|
>
|
||||||
<option value="">Select role</option>
|
<option value="">Select role</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="ADMIN">Admin</option>
|
||||||
<option value="user">User</option>
|
<option value="USER">User</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -73,15 +147,30 @@ export function RegisterUserPage() {
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
Notes
|
Notes
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
<Button
|
||||||
Register User
|
type="submit"
|
||||||
|
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "Registering..." : "Register User"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ export interface CourseCategory {
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCourseCategoryRequest {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GetCourseCategoriesResponse {
|
export interface GetCourseCategoriesResponse {
|
||||||
message: string
|
message: string
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user