Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ CLAUDE.md
.agents/
.claude/
skills-lock.json
.DS_Store
60 changes: 60 additions & 0 deletions src/lib/admin/AdminListView.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<script lang="ts">
import FilterDropdown from './FilterDropdown.svelte';
import PaginatedTable from './PaginatedTable.svelte';
import SearchInput from './SearchInput.svelte';
import type { Snippet } from 'svelte';
import { fly } from 'svelte/transition';

interface Column {
key: string;
header: string;
searchable?: boolean;
sortable?: boolean;
}

interface CellContext {
row: Record<string, unknown>;
column: Column;
value: unknown;
}

/* eslint-disable prefer-const */
let {
title,
data,
columns,
rowKey,
filterKey = '',
filterOptions = ['all', 'Not Started', 'In Progress', 'Completed'],
searchPlaceholder = 'Search applicant',
onRowClick,
cell,
}: {
title: string;
data: Record<string, unknown>[];
columns: Column[];
rowKey: string;
filterKey?: string;
filterOptions?: string[];
searchPlaceholder?: string;
onRowClick?: (row: Record<string, unknown>) => void;
cell?: Snippet<[CellContext]>;
} = $props();
/* eslint-enable prefer-const */

let searchTerm = $state('');
let filterValue = $state('all');
</script>

<div class="flex w-full flex-col gap-6 px-8 py-12" in:fly={{ y: 12, duration: 280 }}>
<h1 class="text-csi-white text-4xl font-bold">{title}</h1>

<div class="flex items-center gap-4">
<div class="flex-1">
<SearchInput bind:value={searchTerm} placeholder={searchPlaceholder} />
</div>
<FilterDropdown bind:value={filterValue} options={filterOptions} />
</div>

<PaginatedTable {data} {columns} {rowKey} {searchTerm} {filterKey} {filterValue} {onRowClick} {cell} />
</div>
9 changes: 7 additions & 2 deletions src/lib/admin/FilterDropdown.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script lang="ts">
import { fly } from 'svelte/transition';

/* eslint-disable prefer-const */
let {
value = $bindable('all'),
Expand Down Expand Up @@ -36,7 +38,7 @@
<button
type="button"
onclick={() => (open = !open)}
class="text-csi-white hover:border-csi-blue flex items-center gap-2 rounded-lg border border-[#5A5A5A] bg-transparent px-4 py-2.5 text-sm transition-colors"
class="text-csi-white hover:border-csi-blue flex items-center gap-2 rounded-lg border border-[#5A5A5A] bg-transparent px-4 py-2.5 text-sm transition-colors duration-150"
>
Filter
{#if value !== 'all'}
Expand All @@ -45,7 +47,10 @@
</button>

{#if open}
<div class="absolute top-full right-0 z-10 mt-1 min-w-[160px] rounded-lg bg-[#2A2A2D] py-1 shadow-lg">
<div
class="absolute top-full right-0 z-10 mt-1 min-w-[160px] rounded-lg bg-[#2A2A2D] py-1 shadow-lg"
transition:fly={{ y: -4, duration: 140 }}
>
{#each options as option (option)}
<button
type="button"
Expand Down
62 changes: 51 additions & 11 deletions src/lib/admin/PaginatedTable.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<script lang="ts">
import Pagination from './Pagination.svelte';
import type { Snippet } from 'svelte';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';

interface Column {
key: string;
header: string;
searchable?: boolean;
sortable?: boolean;
}

interface CellContext {
Expand All @@ -18,29 +21,46 @@
let {
data,
columns,
rowKey,
searchTerm = '',
filterKey = '',
filterValue = 'all',
sortKey = '',
sortDirection = 'asc',
pageSize = 20,
onRowClick,
cell,
}: {
data: Record<string, unknown>[];
columns: Column[];
rowKey: string;
searchTerm?: string;
filterKey?: string;
filterValue?: string;
sortKey?: string;
sortDirection?: 'asc' | 'desc';
pageSize?: number;
onRowClick?: (row: Record<string, unknown>) => void;
cell?: Snippet<[CellContext]>;
} = $props();
/* eslint-enable prefer-const */

let currentPage = $state(1);
let sortKey = $state('');
let sortDirection = $state<'asc' | 'desc'>('asc');

function handleSort(key: string) {
if (sortKey !== key) {
sortKey = key;
sortDirection = 'asc';
} else if (sortDirection === 'asc') {
sortDirection = 'desc';
} else {
sortKey = '';
sortDirection = 'asc';
}
}

function ariaSortFor(key: string): 'ascending' | 'descending' | 'none' {
if (sortKey !== key) return 'none';
return sortDirection === 'asc' ? 'ascending' : 'descending';
}

const filteredBySearch = $derived.by(() => {
if (!searchTerm) return data;
Expand Down Expand Up @@ -96,18 +116,38 @@
<thead>
<tr class="border-b border-[#2A2A2D]">
{#each columns as col (col.key)}
<th
class="text-csi-neutral-400 px-6 py-4 text-left text-xs font-medium tracking-wider uppercase"
>
{col.header}
<th aria-sort={ariaSortFor(col.key)}>
{#if col.sortable}
<button
type="button"
onclick={() => handleSort(col.key)}
class="text-csi-neutral-400 hover:text-csi-white flex w-full items-center gap-1.5 px-6 py-4 text-left text-xs font-medium tracking-wider uppercase transition-colors duration-150"
>
{col.header}
{#if sortKey === col.key}
<span class="text-csi-blue">
{sortDirection === 'asc' ? '\u2191' : '\u2193'}
</span>
{/if}
</button>
{:else}
<div
class="text-csi-neutral-400 px-6 py-4 text-left text-xs font-medium tracking-wider uppercase"
>
{col.header}
</div>
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each paginatedData as row, i (i)}
{#each paginatedData as row (row[rowKey] as string | number)}
<tr
class="border-b border-[#2A2A2D] transition-colors hover:bg-[#2A2A2D]
animate:flip={{ duration: 220 }}
in:fade={{ duration: 180 }}
out:fade={{ duration: 120 }}
class="border-b border-[#2A2A2D] transition-colors duration-150 last:border-b-0 hover:bg-[#2A2A2D]
{onRowClick ? 'cursor-pointer' : ''}"
onclick={() => onRowClick?.(row)}
>
Expand All @@ -125,7 +165,7 @@

{#if paginatedData.length === 0}
<tr>
<td colspan={columns.length} class="text-csi-neutral-400 px-6 py-8 text-center text-sm">
<td colspan={columns.length} class="text-csi-neutral-400 px-6 py-12 text-center text-sm">
No results found
</td>
</tr>
Expand Down
10 changes: 6 additions & 4 deletions src/lib/admin/Pagination.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,29 @@
</script>

{#if totalPages > 1}
<div class="mt-4 flex items-center justify-center gap-4 border-t border-[#2A2A2D] pt-4">
<div class="flex items-center justify-center gap-4 border-t border-[#2A2A2D] px-6 py-4">
<button
class="text-csi-white hover:bg-csi-neutral-900 rounded-lg bg-[#2A2A2D] p-2 transition-colors disabled:cursor-not-allowed disabled:opacity-30"
class="text-csi-white hover:bg-csi-neutral-900 rounded-md p-1.5 transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-30"
disabled={currentPage === 1}
onclick={() => {
currentPage = Math.max(1, currentPage - 1);
}}
aria-label="Previous page"
>
<ChevronLeft class="h-4 w-4" />
</button>

<span class="text-csi-neutral-400 text-sm">
<span class="text-csi-neutral-400 text-sm tabular-nums">
Page {currentPage} of {totalPages}
</span>

<button
class="text-csi-white hover:bg-csi-neutral-900 rounded-lg bg-[#2A2A2D] p-2 transition-colors disabled:cursor-not-allowed disabled:opacity-30"
class="text-csi-white hover:bg-csi-neutral-900 rounded-md p-1.5 transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-30"
disabled={currentPage >= totalPages}
onclick={() => {
currentPage = Math.min(totalPages, currentPage + 1);
}}
aria-label="Next page"
>
<ChevronRight class="h-4 w-4" />
</button>
Expand Down
71 changes: 0 additions & 71 deletions src/lib/admin/SortDropdown.svelte

This file was deleted.

8 changes: 8 additions & 0 deletions src/lib/admin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,11 @@ export interface GradeInput {
max_score: number;
remarks?: string;
}

/** Row shape for the constiquiz respondent list page (P02-001). */
export interface QuizRespondent {
user_id: string;
full_name: string;
status: 'Not Started' | 'In Progress' | 'Completed';
current_score: number;
}
Loading
Loading