mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 12:12:09 +01:00
feat: implement comprehensive email system with rate limiting and extensive test suite
- Add robust email service with rate limiting and configuration management - Implement shared rate limiter utility for consistent API protection - Create comprehensive test suite for core processing pipeline - Add API tests for dashboard metrics and authentication routes - Fix date range picker infinite loop issue - Improve session lookup in refresh sessions API - Refactor session API routing with better code organization - Update processing pipeline status monitoring - Clean up leftover files and improve code formatting
This commit is contained in:
@ -22,16 +22,408 @@ import { Label } from "@/components/ui/label";
|
||||
import { formatCategory } from "@/lib/format-enums";
|
||||
import type { ChatSession } from "../../../lib/types";
|
||||
|
||||
// Placeholder for a SessionListItem component to be created later
|
||||
// For now, we'll display some basic info directly.
|
||||
// import SessionListItem from "../../../components/SessionListItem";
|
||||
|
||||
// TODO: Consider moving filter/sort types to lib/types.ts if they become complex
|
||||
interface FilterOptions {
|
||||
categories: string[];
|
||||
languages: string[];
|
||||
}
|
||||
|
||||
interface FilterSectionProps {
|
||||
filtersExpanded: boolean;
|
||||
setFiltersExpanded: (expanded: boolean) => void;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (term: string) => void;
|
||||
selectedCategory: string;
|
||||
setSelectedCategory: (category: string) => void;
|
||||
selectedLanguage: string;
|
||||
setSelectedLanguage: (language: string) => void;
|
||||
startDate: string;
|
||||
setStartDate: (date: string) => void;
|
||||
endDate: string;
|
||||
setEndDate: (date: string) => void;
|
||||
sortKey: string;
|
||||
setSortKey: (key: string) => void;
|
||||
sortOrder: string;
|
||||
setSortOrder: (order: string) => void;
|
||||
filterOptions: FilterOptions;
|
||||
searchHeadingId: string;
|
||||
filtersHeadingId: string;
|
||||
filterContentId: string;
|
||||
categoryFilterId: string;
|
||||
categoryHelpId: string;
|
||||
languageFilterId: string;
|
||||
languageHelpId: string;
|
||||
sortOrderId: string;
|
||||
sortOrderHelpId: string;
|
||||
}
|
||||
|
||||
function FilterSection({
|
||||
filtersExpanded,
|
||||
setFiltersExpanded,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
selectedLanguage,
|
||||
setSelectedLanguage,
|
||||
startDate,
|
||||
setStartDate,
|
||||
endDate,
|
||||
setEndDate,
|
||||
sortKey,
|
||||
setSortKey,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
filterOptions,
|
||||
searchHeadingId,
|
||||
filtersHeadingId,
|
||||
filterContentId,
|
||||
categoryFilterId,
|
||||
categoryHelpId,
|
||||
languageFilterId,
|
||||
languageHelpId,
|
||||
sortOrderId,
|
||||
sortOrderHelpId,
|
||||
}: FilterSectionProps) {
|
||||
return (
|
||||
<section aria-labelledby={searchHeadingId}>
|
||||
<h2 id={searchHeadingId} className="sr-only">
|
||||
Search and Filter Sessions
|
||||
</h2>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Label htmlFor="search-sessions" className="sr-only">
|
||||
Search sessions
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search-sessions"
|
||||
type="text"
|
||||
placeholder="Search sessions..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
className="w-full justify-between"
|
||||
aria-expanded={filtersExpanded}
|
||||
aria-controls={filterContentId}
|
||||
aria-describedby={filtersHeadingId}
|
||||
>
|
||||
<span id={filtersHeadingId}>Advanced Filters</span>
|
||||
{filtersExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{filtersExpanded && (
|
||||
<CardContent id={filterContentId}>
|
||||
<fieldset>
|
||||
<legend className="sr-only">Filter and sort options</legend>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor={categoryFilterId}>Category</Label>
|
||||
<select
|
||||
id={categoryFilterId}
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
||||
aria-describedby={categoryHelpId}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{filterOptions.categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{formatCategory(category)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div id={categoryHelpId} className="sr-only">
|
||||
Filter sessions by category
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={languageFilterId}>Language</Label>
|
||||
<select
|
||||
id={languageFilterId}
|
||||
value={selectedLanguage}
|
||||
onChange={(e) => setSelectedLanguage(e.target.value)}
|
||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
||||
aria-describedby={languageHelpId}
|
||||
>
|
||||
<option value="">All Languages</option>
|
||||
{filterOptions.languages.map((language) => (
|
||||
<option key={language} value={language}>
|
||||
{language.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div id={languageHelpId} className="sr-only">
|
||||
Filter sessions by language
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="start-date">Start Date</Label>
|
||||
<Input
|
||||
id="start-date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="end-date">End Date</Label>
|
||||
<Input
|
||||
id="end-date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sort-by">Sort By</Label>
|
||||
<select
|
||||
id="sort-by"
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value)}
|
||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="startTime">Start Time</option>
|
||||
<option value="sessionId">Session ID</option>
|
||||
<option value="category">Category</option>
|
||||
<option value="language">Language</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={sortOrderId}>Sort Order</Label>
|
||||
<select
|
||||
id={sortOrderId}
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
||||
aria-describedby={sortOrderHelpId}
|
||||
>
|
||||
<option value="desc">Newest First</option>
|
||||
<option value="asc">Oldest First</option>
|
||||
</select>
|
||||
<div id={sortOrderHelpId} className="sr-only">
|
||||
Choose ascending or descending order
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionListProps {
|
||||
sessions: ChatSession[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
resultsHeadingId: string;
|
||||
}
|
||||
|
||||
function SessionList({
|
||||
sessions,
|
||||
loading,
|
||||
error,
|
||||
resultsHeadingId,
|
||||
}: SessionListProps) {
|
||||
return (
|
||||
<section aria-labelledby={resultsHeadingId}>
|
||||
<h2 id={resultsHeadingId} className="sr-only">
|
||||
Session Results
|
||||
</h2>
|
||||
|
||||
<output aria-live="polite" className="sr-only">
|
||||
{loading && "Loading sessions..."}
|
||||
{error && `Error loading sessions: ${error}`}
|
||||
{!loading &&
|
||||
!error &&
|
||||
sessions.length > 0 &&
|
||||
`Found ${sessions.length} sessions`}
|
||||
{!loading && !error && sessions.length === 0 && "No sessions found"}
|
||||
</output>
|
||||
|
||||
{loading && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Loading sessions...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
className="text-center py-8 text-destructive"
|
||||
role="alert"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Error loading sessions: {error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && sessions.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No sessions found. Try adjusting your search criteria.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && sessions.length > 0 && (
|
||||
<ul className="space-y-4" role="list">
|
||||
{sessions.map((session) => (
|
||||
<li key={session.id}>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<article>
|
||||
<header className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium text-base mb-1">
|
||||
Session{" "}
|
||||
{session.sessionId ||
|
||||
session.id.substring(0, 8) + "..."}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Clock
|
||||
className="h-3 w-3 mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{new Date(session.startTime).toLocaleDateString()}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(session.startTime).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/dashboard/sessions/${session.id}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
aria-label={`View details for session ${session.sessionId || session.id}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">View Details</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{session.category && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Filter className="h-3 w-3" aria-hidden="true" />
|
||||
{formatCategory(session.category)}
|
||||
</Badge>
|
||||
)}
|
||||
{session.language && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Globe className="h-3 w-3" aria-hidden="true" />
|
||||
{session.language.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{session.summary ? (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{session.summary}
|
||||
</p>
|
||||
) : session.initialMsg ? (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{session.initialMsg}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
setCurrentPage: (page: number | ((prev: number) => number)) => void;
|
||||
}
|
||||
|
||||
function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
setCurrentPage,
|
||||
}: PaginationProps) {
|
||||
if (totalPages === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex justify-center items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||
}
|
||||
disabled={currentPage === totalPages}
|
||||
className="gap-2"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionsPage() {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -48,45 +440,29 @@ export default function SessionsPage() {
|
||||
const sortOrderId = useId();
|
||||
const sortOrderHelpId = useId();
|
||||
const resultsHeadingId = useId();
|
||||
const startDateFilterId = useId();
|
||||
const startDateHelpId = useId();
|
||||
const endDateFilterId = useId();
|
||||
const endDateHelpId = useId();
|
||||
const sortKeyId = useId();
|
||||
const sortKeyHelpId = useId();
|
||||
|
||||
// Filter states
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [sortKey, setSortKey] = useState("startTime");
|
||||
const [sortOrder, setSortOrder] = useState("desc");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [pageSize] = useState(10);
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
categories: [],
|
||||
languages: [],
|
||||
});
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string>("");
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
|
||||
// Sort states
|
||||
const [sortKey, setSortKey] = useState<string>("startTime"); // Default sort key
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); // Default sort order
|
||||
|
||||
// Debounce search term to avoid excessive API calls
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [pageSize, _setPageSize] = useState(10); // Or make this configurable
|
||||
|
||||
// UI states
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
}, 500); // 500ms delay
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
}, 500);
|
||||
return () => clearTimeout(timerId);
|
||||
}, [searchTerm]);
|
||||
|
||||
const fetchFilterOptions = useCallback(async () => {
|
||||
@ -158,10 +534,8 @@ export default function SessionsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page heading for screen readers */}
|
||||
<h1 className="sr-only">Sessions Management</h1>
|
||||
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
@ -171,376 +545,47 @@ export default function SessionsPage() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Search Input */}
|
||||
<section aria-labelledby={searchHeadingId}>
|
||||
<h2 id={searchHeadingId} className="sr-only">
|
||||
Search Sessions
|
||||
</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search sessions (ID, category, initial message...)"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
aria-label="Search sessions by ID, category, or message content"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
<FilterSection
|
||||
filtersExpanded={filtersExpanded}
|
||||
setFiltersExpanded={setFiltersExpanded}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
selectedLanguage={selectedLanguage}
|
||||
setSelectedLanguage={setSelectedLanguage}
|
||||
startDate={startDate}
|
||||
setStartDate={setStartDate}
|
||||
endDate={endDate}
|
||||
setEndDate={setEndDate}
|
||||
sortKey={sortKey}
|
||||
setSortKey={setSortKey}
|
||||
sortOrder={sortOrder}
|
||||
setSortOrder={setSortOrder}
|
||||
filterOptions={filterOptions}
|
||||
searchHeadingId={searchHeadingId}
|
||||
filtersHeadingId={filtersHeadingId}
|
||||
filterContentId={filterContentId}
|
||||
categoryFilterId={categoryFilterId}
|
||||
categoryHelpId={categoryHelpId}
|
||||
languageFilterId={languageFilterId}
|
||||
languageHelpId={languageHelpId}
|
||||
sortOrderId={sortOrderId}
|
||||
sortOrderHelpId={sortOrderHelpId}
|
||||
/>
|
||||
|
||||
{/* Filter and Sort Controls */}
|
||||
<section aria-labelledby={filtersHeadingId}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" aria-hidden="true" />
|
||||
<CardTitle id={filtersHeadingId} className="text-lg">
|
||||
Filters & Sorting
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
className="gap-2"
|
||||
aria-expanded={filtersExpanded}
|
||||
aria-controls={filterContentId}
|
||||
>
|
||||
{filtersExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
Hide
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Show
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{filtersExpanded && (
|
||||
<CardContent id={filterContentId}>
|
||||
<fieldset>
|
||||
<legend className="sr-only">
|
||||
Session Filters and Sorting Options
|
||||
</legend>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
{/* Category Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={categoryFilterId}>Category</Label>
|
||||
<select
|
||||
id={categoryFilterId}
|
||||
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
aria-describedby={categoryHelpId}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{filterOptions.categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{formatCategory(cat)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div id={categoryHelpId} className="sr-only">
|
||||
Filter sessions by category type
|
||||
</div>
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
loading={loading}
|
||||
error={error}
|
||||
resultsHeadingId={resultsHeadingId}
|
||||
/>
|
||||
|
||||
{/* Language Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={languageFilterId}>Language</Label>
|
||||
<select
|
||||
id={languageFilterId}
|
||||
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={selectedLanguage}
|
||||
onChange={(e) => setSelectedLanguage(e.target.value)}
|
||||
aria-describedby={languageHelpId}
|
||||
>
|
||||
<option value="">All Languages</option>
|
||||
{filterOptions.languages.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{lang.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div id={languageHelpId} className="sr-only">
|
||||
Filter sessions by language
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Start Date Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={startDateFilterId}>Start Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
id={startDateFilterId}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
aria-describedby={startDateHelpId}
|
||||
/>
|
||||
<div id={startDateHelpId} className="sr-only">
|
||||
Filter sessions from this date onwards
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* End Date Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={endDateFilterId}>End Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
id={endDateFilterId}
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
aria-describedby={endDateHelpId}
|
||||
/>
|
||||
<div id={endDateHelpId} className="sr-only">
|
||||
Filter sessions up to this date
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort Key */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={sortKeyId}>Sort By</Label>
|
||||
<select
|
||||
id={sortKeyId}
|
||||
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value)}
|
||||
aria-describedby={sortKeyHelpId}
|
||||
>
|
||||
<option value="startTime">Start Time</option>
|
||||
<option value="category">Category</option>
|
||||
<option value="language">Language</option>
|
||||
<option value="sentiment">Sentiment</option>
|
||||
<option value="messagesSent">Messages Sent</option>
|
||||
<option value="avgResponseTime">
|
||||
Avg. Response Time
|
||||
</option>
|
||||
</select>
|
||||
<div id={sortKeyHelpId} className="sr-only">
|
||||
Choose field to sort sessions by
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort Order */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={sortOrderId}>Order</Label>
|
||||
<select
|
||||
id={sortOrderId}
|
||||
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={sortOrder}
|
||||
onChange={(e) =>
|
||||
setSortOrder(e.target.value as "asc" | "desc")
|
||||
}
|
||||
aria-describedby={sortOrderHelpId}
|
||||
>
|
||||
<option value="desc">Descending</option>
|
||||
<option value="asc">Ascending</option>
|
||||
</select>
|
||||
<div id={sortOrderHelpId} className="sr-only">
|
||||
Choose ascending or descending order
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Results section */}
|
||||
<section aria-labelledby={resultsHeadingId}>
|
||||
<h2 id={resultsHeadingId} className="sr-only">
|
||||
Session Results
|
||||
</h2>
|
||||
|
||||
{/* Live region for screen reader announcements */}
|
||||
<output aria-live="polite" className="sr-only">
|
||||
{loading && "Loading sessions..."}
|
||||
{error && `Error loading sessions: ${error}`}
|
||||
{!loading &&
|
||||
!error &&
|
||||
sessions.length > 0 &&
|
||||
`Found ${sessions.length} sessions`}
|
||||
{!loading && !error && sessions.length === 0 && "No sessions found"}
|
||||
</output>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Loading sessions...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
className="text-center py-8 text-destructive"
|
||||
role="alert"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Error: {error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && sessions.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{debouncedSearchTerm
|
||||
? `No sessions found for "${debouncedSearchTerm}".`
|
||||
: "No sessions found."}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sessions List */}
|
||||
{!loading && !error && sessions.length > 0 && (
|
||||
<ul aria-label="Chat sessions" className="grid gap-4">
|
||||
{sessions.map((session) => (
|
||||
<li key={session.id}>
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-6">
|
||||
<article aria-labelledby={`session-${session.id}-title`}>
|
||||
<header className="flex justify-between items-start mb-4">
|
||||
<div className="space-y-2 flex-1">
|
||||
<h3
|
||||
id={`session-${session.id}-title`}
|
||||
className="sr-only"
|
||||
>
|
||||
Session {session.sessionId || session.id} from{" "}
|
||||
{new Date(session.startTime).toLocaleDateString()}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
ID
|
||||
</Badge>
|
||||
<code className="text-sm text-muted-foreground font-mono truncate max-w-24">
|
||||
{session.sessionId || session.id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Clock
|
||||
className="h-3 w-3 mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{new Date(session.startTime).toLocaleDateString()}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(session.startTime).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/dashboard/sessions/${session.id}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
aria-label={`View details for session ${session.sessionId || session.id}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">
|
||||
View Details
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{session.category && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Filter className="h-3 w-3" aria-hidden="true" />
|
||||
{formatCategory(session.category)}
|
||||
</Badge>
|
||||
)}
|
||||
{session.language && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Globe className="h-3 w-3" aria-hidden="true" />
|
||||
{session.language.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{session.summary ? (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{session.summary}
|
||||
</p>
|
||||
) : session.initialMsg ? (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{session.initialMsg}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex justify-center items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||
}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||
}
|
||||
disabled={currentPage === totalPages}
|
||||
className="gap-2"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
setCurrentPage={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user