Skip to content

Commit c5fb16c

Browse files
authored
Merge pull request #46 from programmerbar/admin-features
Admin features
2 parents 71a14a7 + 4f75d09 commit c5fb16c

File tree

6 files changed

+367
-11
lines changed

6 files changed

+367
-11
lines changed

programmerbar-web/src/lib/server/services/referral.service.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Database } from '$lib/server/db/drizzle';
2-
import { eq, and } from 'drizzle-orm';
2+
import { eq, and, count } from 'drizzle-orm';
33
import { referrals, users, type ReferralInsert } from '$lib/server/db/schemas';
44
import { nanoid } from 'nanoid';
55
import type { ShiftService } from './shift.service';
@@ -179,4 +179,21 @@ export class ReferralService {
179179

180180
return stats;
181181
}
182+
183+
async getAllReferrals(options?: { limit?: number; offset?: number }) {
184+
return await this.#db.query.referrals.findMany({
185+
orderBy: (row, { desc }) => [desc(row.createdAt)],
186+
limit: options?.limit,
187+
offset: options?.offset,
188+
with: {
189+
referrer: true,
190+
referred: true
191+
}
192+
});
193+
}
194+
195+
async countAllReferrals() {
196+
const result = await this.#db.select({ count: count() }).from(referrals);
197+
return result[0]?.count ?? 0;
198+
}
182199
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { json } from '@sveltejs/kit';
2+
import type { RequestHandler } from './$types';
3+
4+
export const GET: RequestHandler = async () => {
5+
try {
6+
const response = await fetch('https://naas.isalman.dev/no');
7+
8+
if (!response.ok) {
9+
return json({ error: 'Failed to fetch rejection reason' }, { status: response.status });
10+
}
11+
12+
const data = await response.json();
13+
14+
// Add CORS headers to allow frontend to access this endpoint
15+
return json(data, {
16+
headers: {
17+
'Cache-Control': 'public, max-age=60'
18+
}
19+
});
20+
} catch (error) {
21+
console.error('Error fetching from naas.isalman.dev:', error);
22+
return json({ error: 'Failed to fetch rejection reason' }, { status: 500 });
23+
}
24+
};

programmerbar-web/src/routes/(portal)/portal/admin/bruker/[id]/+page.server.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,16 @@ export const load: PageServerLoad = async ({ params, locals }) => {
1414
}
1515

1616
const user = await locals.userService.findById(userId);
17-
if (!user) {
18-
throw error(404, 'User not found');
17+
if (!user) {
18+
throw error(404, 'User not found');
1919
}
2020

21-
const [userShifts, unclaimedBeers, referrals, shifts] = await Promise.all([
22-
await locals.shiftService.findCompletedShiftsByUserId(userId),
23-
await locals.beerService.getTotalAvailableBeers(userId),
24-
await locals.referralService.getReferralStats(userId),
25-
await locals.shiftService.findUpcomingShiftsByUserId(userId),
26-
]);
27-
21+
const [userShifts, unclaimedBeers, referrals, shifts] = await Promise.all([
22+
locals.shiftService.findCompletedShiftsByUserId(userId),
23+
locals.beerService.getTotalAvailableBeers(userId),
24+
locals.referralService.getReferralStats(userId),
25+
locals.shiftService.findUpcomingShiftsByUserId(userId)
26+
]);
2827

2928
return {
3029
user,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { redirect } from '@sveltejs/kit';
2+
import type { PageServerLoad } from './$types';
3+
4+
const PAGE_SIZE = 20;
5+
6+
export const load: PageServerLoad = async ({ locals, url }) => {
7+
if (locals.user?.role !== 'board') {
8+
throw redirect(307, '/portal');
9+
}
10+
11+
const page = Number(url.searchParams.get('page') ?? '1');
12+
const requestedPage = Number.isNaN(page) || page < 1 ? 1 : page;
13+
14+
const totalCount = await locals.referralService.countAllReferrals();
15+
const pageCount = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
16+
const currentPage = Math.min(requestedPage, pageCount);
17+
18+
const referrals = await locals.referralService.getAllReferrals({
19+
limit: PAGE_SIZE,
20+
offset: (currentPage - 1) * PAGE_SIZE
21+
});
22+
23+
return {
24+
referrals,
25+
pagination: {
26+
currentPage,
27+
pageCount,
28+
pageSize: PAGE_SIZE,
29+
totalCount
30+
}
31+
};
32+
};
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
<script lang="ts">
2+
import ButtonLink from '$lib/components/ui/ButtonLink.svelte';
3+
import Heading from '$lib/components/ui/Heading.svelte';
4+
import { UserPlus, ChevronLeft, ChevronRight } from '@lucide/svelte';
5+
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
6+
7+
const formatter = new Intl.DateTimeFormat('no-NO', {
8+
dateStyle: 'short',
9+
timeStyle: 'short'
10+
});
11+
12+
let { data } = $props();
13+
14+
function formatTimestamp(value: Date | string | number | null | undefined) {
15+
if (!value) return '-';
16+
const date = value instanceof Date ? value : new Date(value);
17+
return formatter.format(date);
18+
}
19+
20+
function buildQuery(params: Record<string, string | number | null | undefined>) {
21+
const search = new SvelteURLSearchParams();
22+
23+
for (const [key, value] of Object.entries(params)) {
24+
if (value === null || value === undefined || value === '') continue;
25+
search.set(key, String(value));
26+
}
27+
28+
const query = search.toString();
29+
return query ? `?${query}` : '';
30+
}
31+
32+
function getStatusBadge(status: string) {
33+
switch (status) {
34+
case 'completed':
35+
return {
36+
label: 'Fullført',
37+
class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
38+
};
39+
case 'pending':
40+
return {
41+
label: 'Venter',
42+
class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
43+
};
44+
case 'expired':
45+
return {
46+
label: 'Utløpt',
47+
class: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300'
48+
};
49+
default:
50+
return { label: status, class: '' };
51+
}
52+
}
53+
54+
type PaginationItem = { type: 'page'; value: number } | { type: 'ellipsis'; key: string };
55+
56+
const paginationItems = $derived.by(() => {
57+
const { currentPage, pageCount } = data.pagination;
58+
if (pageCount <= 0) {
59+
return [];
60+
}
61+
62+
const pages = new SvelteSet<number>();
63+
64+
pages.add(1);
65+
if (pageCount > 1) {
66+
pages.add(pageCount);
67+
}
68+
if (pageCount > 2) {
69+
pages.add(pageCount - 1);
70+
}
71+
72+
for (let offset = -2; offset <= 2; offset += 1) {
73+
const candidate = currentPage + offset;
74+
if (candidate > 1 && candidate < pageCount) {
75+
pages.add(candidate);
76+
}
77+
}
78+
79+
const sortedPages = Array.from(pages)
80+
.filter((page) => page >= 1 && page <= pageCount)
81+
.sort((a, b) => a - b);
82+
83+
const items: Array<PaginationItem> = [];
84+
85+
for (let index = 0; index < sortedPages.length; index += 1) {
86+
const page = sortedPages[index];
87+
const previous = sortedPages[index - 1];
88+
89+
if (index > 0 && page - previous > 1) {
90+
items.push({ type: 'ellipsis', key: `ellipsis-${previous}-${page}` });
91+
}
92+
93+
items.push({ type: 'page', value: page });
94+
}
95+
96+
return items;
97+
});
98+
99+
const previousPage = $derived.by(() =>
100+
data.pagination.currentPage > 1 ? data.pagination.currentPage - 1 : null
101+
);
102+
103+
const nextPage = $derived.by(() =>
104+
data.pagination.currentPage < data.pagination.pageCount ? data.pagination.currentPage + 1 : null
105+
);
106+
</script>
107+
108+
<svelte:head>
109+
<title>Admin - Referrals</title>
110+
</svelte:head>
111+
112+
<div class="space-y-10">
113+
<!-- Header -->
114+
<div class="flex items-center gap-4">
115+
<UserPlus class="h-6 w-6 text-gray-600 dark:text-gray-300" />
116+
<div>
117+
<Heading>Referrals</Heading>
118+
<p class="mt-1 text-gray-600 dark:text-gray-300">Oversikt over alle referrals i systemet</p>
119+
</div>
120+
</div>
121+
122+
<div class="bg-portal-card border-portal-border rounded-lg border">
123+
<div class="border-portal-border border-b px-6 py-4">
124+
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Alle referrals</h2>
125+
<p class="text-sm text-gray-600 dark:text-gray-300">
126+
Viser {data.referrals.length} av {data.pagination.totalCount} totalt.
127+
</p>
128+
</div>
129+
{#if data.referrals.length === 0}
130+
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
131+
Det er ingen registrerte referrals i databasen ennå.
132+
</div>
133+
{:else}
134+
<div class="overflow-x-auto">
135+
<table class="divide-portal-border w-full min-w-[720px] divide-y divide-gray-200">
136+
<thead class="dark:bg-portal-hover bg-gray-50">
137+
<tr
138+
class="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400"
139+
>
140+
<th class="px-6 py-3 text-left">Opprettet</th>
141+
<th class="px-6 py-3 text-left">Referrer</th>
142+
<th class="px-6 py-3 text-left">Referee</th>
143+
<th class="px-6 py-3 text-left">Status</th>
144+
<th class="px-6 py-3 text-left">Fullført</th>
145+
</tr>
146+
</thead>
147+
<tbody
148+
class="divide-portal-border divide-y divide-gray-200 text-sm text-gray-700 dark:text-gray-300"
149+
>
150+
{#each data.referrals as referral (referral.id)}
151+
{@const statusBadge = getStatusBadge(referral.status)}
152+
<tr class="hover:bg-portal-hover transition-colors">
153+
<td class="px-6 py-4 whitespace-nowrap">
154+
{formatTimestamp(referral.createdAt)}
155+
</td>
156+
<td class="px-6 py-4 whitespace-nowrap">
157+
{referral.referrer?.name ?? 'Ukjent bruker'}
158+
</td>
159+
<td class="px-6 py-4 whitespace-nowrap">
160+
{referral.referred?.name ?? 'Ukjent bruker'}
161+
</td>
162+
<td class="px-6 py-4 whitespace-nowrap">
163+
<span
164+
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold {statusBadge.class}"
165+
>
166+
{statusBadge.label}
167+
</span>
168+
</td>
169+
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
170+
{formatTimestamp(referral.completedAt)}
171+
</td>
172+
</tr>
173+
{/each}
174+
</tbody>
175+
</table>
176+
</div>
177+
<div class="border-portal-border flex justify-center border-t px-6 py-4">
178+
<nav aria-label="Paginering" class="flex items-center gap-2">
179+
{#if previousPage}
180+
<ButtonLink
181+
href={buildQuery({ page: previousPage })}
182+
intent="outline"
183+
size="sm"
184+
class="gap-1"
185+
>
186+
<ChevronLeft class="h-4 w-4" />
187+
Forrige
188+
</ButtonLink>
189+
{:else}
190+
<span
191+
class="border-portal-border dark:bg-portal-hover flex items-center gap-1 rounded-lg border border-dashed bg-gray-50 px-3 py-1.5 text-sm text-gray-400 dark:text-gray-500"
192+
>
193+
<ChevronLeft class="h-4 w-4" />
194+
Forrige
195+
</span>
196+
{/if}
197+
198+
{#each paginationItems as item (item.type === 'page' ? item.value : item.key)}
199+
{#if item.type === 'ellipsis'}
200+
<span class="px-2 text-sm text-gray-400 dark:text-gray-500">…</span>
201+
{:else if item.value === data.pagination.currentPage}
202+
<span
203+
class="border-primary-dark bg-primary dark:border-primary-dark dark:bg-primary rounded-lg border px-3 py-1.5 text-sm font-medium text-white"
204+
>
205+
{item.value}
206+
</span>
207+
{:else}
208+
<ButtonLink href={buildQuery({ page: item.value })} intent="outline" size="sm">
209+
{item.value}
210+
</ButtonLink>
211+
{/if}
212+
{/each}
213+
214+
{#if nextPage}
215+
<ButtonLink
216+
href={buildQuery({ page: nextPage })}
217+
intent="outline"
218+
size="sm"
219+
class="gap-1"
220+
>
221+
Neste
222+
<ChevronRight class="h-4 w-4" />
223+
</ButtonLink>
224+
{:else}
225+
<span
226+
class="border-portal-border dark:bg-portal-hover flex items-center gap-1 rounded-lg border border-dashed bg-gray-50 px-3 py-1.5 text-sm text-gray-400 dark:text-gray-500"
227+
>
228+
Neste
229+
<ChevronRight class="h-4 w-4" />
230+
</span>
231+
{/if}
232+
</nav>
233+
</div>
234+
{/if}
235+
</div>
236+
</div>

0 commit comments

Comments
 (0)