ui+plus
This commit is contained in:
parent
e35defe48a
commit
cd2ed66960
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,12 +81,22 @@ export function CourseCategoryPage() {
|
|||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page header */}
|
||||
<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 ? (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +352,7 @@ export function IssuesPage() {
|
|||
Review and manage user-reported issues across the platform.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
|
|
@ -356,6 +364,14 @@ export function IssuesPage() {
|
|||
<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">
|
||||
|
|
|
|||
|
|
@ -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,25 +330,94 @@ 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">
|
||||
<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"
|
||||
|
|
@ -348,11 +447,58 @@ export function NotificationsPage() {
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ export interface CourseCategory {
|
|||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreateCourseCategoryRequest {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface GetCourseCategoriesResponse {
|
||||
message: string
|
||||
data: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user