Skip to content
Merged
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
19 changes: 18 additions & 1 deletion programmerbar-web/src/lib/server/services/referral.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Database } from '$lib/server/db/drizzle';
import { eq, and } from 'drizzle-orm';
import { eq, and, count } from 'drizzle-orm';
import { referrals, users, type ReferralInsert } from '$lib/server/db/schemas';
import { nanoid } from 'nanoid';
import type { ShiftService } from './shift.service';
Expand Down Expand Up @@ -179,4 +179,21 @@ export class ReferralService {

return stats;
}

async getAllReferrals(options?: { limit?: number; offset?: number }) {
return await this.#db.query.referrals.findMany({
orderBy: (row, { desc }) => [desc(row.createdAt)],
limit: options?.limit,
offset: options?.offset,
with: {
referrer: true,
referred: true
}
});
}

async countAllReferrals() {
const result = await this.#db.select({ count: count() }).from(referrals);
return result[0]?.count ?? 0;
}
}
24 changes: 24 additions & 0 deletions programmerbar-web/src/routes/(app)/api/rejection/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async () => {
try {
const response = await fetch('https://naas.isalman.dev/no');

if (!response.ok) {
return json({ error: 'Failed to fetch rejection reason' }, { status: response.status });
}

const data = await response.json();

// Add CORS headers to allow frontend to access this endpoint
return json(data, {
headers: {
'Cache-Control': 'public, max-age=60'
}
});
} catch (error) {
console.error('Error fetching from naas.isalman.dev:', error);
return json({ error: 'Failed to fetch rejection reason' }, { status: 500 });
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,16 @@ export const load: PageServerLoad = async ({ params, locals }) => {
}

const user = await locals.userService.findById(userId);
if (!user) {
throw error(404, 'User not found');
if (!user) {
throw error(404, 'User not found');
}

const [userShifts, unclaimedBeers, referrals, shifts] = await Promise.all([
await locals.shiftService.findCompletedShiftsByUserId(userId),
await locals.beerService.getTotalAvailableBeers(userId),
await locals.referralService.getReferralStats(userId),
await locals.shiftService.findUpcomingShiftsByUserId(userId),
]);

const [userShifts, unclaimedBeers, referrals, shifts] = await Promise.all([
locals.shiftService.findCompletedShiftsByUserId(userId),
locals.beerService.getTotalAvailableBeers(userId),
locals.referralService.getReferralStats(userId),
locals.shiftService.findUpcomingShiftsByUserId(userId)
]);

return {
user,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

const PAGE_SIZE = 20;

export const load: PageServerLoad = async ({ locals, url }) => {
if (locals.user?.role !== 'board') {
throw redirect(307, '/portal');
}

const page = Number(url.searchParams.get('page') ?? '1');
const requestedPage = Number.isNaN(page) || page < 1 ? 1 : page;

const totalCount = await locals.referralService.countAllReferrals();
const pageCount = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const currentPage = Math.min(requestedPage, pageCount);

const referrals = await locals.referralService.getAllReferrals({
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE
});

return {
referrals,
pagination: {
currentPage,
pageCount,
pageSize: PAGE_SIZE,
totalCount
}
};
};
236 changes: 236 additions & 0 deletions programmerbar-web/src/routes/(portal)/portal/admin/refs/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
<script lang="ts">
import ButtonLink from '$lib/components/ui/ButtonLink.svelte';
import Heading from '$lib/components/ui/Heading.svelte';
import { UserPlus, ChevronLeft, ChevronRight } from '@lucide/svelte';
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';

const formatter = new Intl.DateTimeFormat('no-NO', {
dateStyle: 'short',
timeStyle: 'short'
});

let { data } = $props();

function formatTimestamp(value: Date | string | number | null | undefined) {
if (!value) return '-';
const date = value instanceof Date ? value : new Date(value);
return formatter.format(date);
}

function buildQuery(params: Record<string, string | number | null | undefined>) {
const search = new SvelteURLSearchParams();

for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined || value === '') continue;
search.set(key, String(value));
}

const query = search.toString();
return query ? `?${query}` : '';
}

function getStatusBadge(status: string) {
switch (status) {
case 'completed':
return {
label: 'Fullført',
class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
};
case 'pending':
return {
label: 'Venter',
class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
};
case 'expired':
return {
label: 'Utløpt',
class: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300'
};
default:
return { label: status, class: '' };
}
}

type PaginationItem = { type: 'page'; value: number } | { type: 'ellipsis'; key: string };

const paginationItems = $derived.by(() => {
const { currentPage, pageCount } = data.pagination;
if (pageCount <= 0) {
return [];
}

const pages = new SvelteSet<number>();

pages.add(1);
if (pageCount > 1) {
pages.add(pageCount);
}
if (pageCount > 2) {
pages.add(pageCount - 1);
}

for (let offset = -2; offset <= 2; offset += 1) {
const candidate = currentPage + offset;
if (candidate > 1 && candidate < pageCount) {
pages.add(candidate);
}
}

const sortedPages = Array.from(pages)
.filter((page) => page >= 1 && page <= pageCount)
.sort((a, b) => a - b);

const items: Array<PaginationItem> = [];

for (let index = 0; index < sortedPages.length; index += 1) {
const page = sortedPages[index];
const previous = sortedPages[index - 1];

if (index > 0 && page - previous > 1) {
items.push({ type: 'ellipsis', key: `ellipsis-${previous}-${page}` });
}

items.push({ type: 'page', value: page });
}

return items;
});

const previousPage = $derived.by(() =>
data.pagination.currentPage > 1 ? data.pagination.currentPage - 1 : null
);

const nextPage = $derived.by(() =>
data.pagination.currentPage < data.pagination.pageCount ? data.pagination.currentPage + 1 : null
);
</script>

<svelte:head>
<title>Admin - Referrals</title>
</svelte:head>

<div class="space-y-10">
<!-- Header -->
<div class="flex items-center gap-4">
<UserPlus class="h-6 w-6 text-gray-600 dark:text-gray-300" />
<div>
<Heading>Referrals</Heading>
<p class="mt-1 text-gray-600 dark:text-gray-300">Oversikt over alle referrals i systemet</p>
</div>
</div>

<div class="bg-portal-card border-portal-border rounded-lg border">
<div class="border-portal-border border-b px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Alle referrals</h2>
<p class="text-sm text-gray-600 dark:text-gray-300">
Viser {data.referrals.length} av {data.pagination.totalCount} totalt.
</p>
</div>
{#if data.referrals.length === 0}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
Det er ingen registrerte referrals i databasen ennå.
</div>
{:else}
<div class="overflow-x-auto">
<table class="divide-portal-border w-full min-w-[720px] divide-y divide-gray-200">
<thead class="dark:bg-portal-hover bg-gray-50">
<tr
class="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400"
>
<th class="px-6 py-3 text-left">Opprettet</th>
<th class="px-6 py-3 text-left">Referrer</th>
<th class="px-6 py-3 text-left">Referee</th>
<th class="px-6 py-3 text-left">Status</th>
<th class="px-6 py-3 text-left">Fullført</th>
</tr>
</thead>
<tbody
class="divide-portal-border divide-y divide-gray-200 text-sm text-gray-700 dark:text-gray-300"
>
{#each data.referrals as referral (referral.id)}
{@const statusBadge = getStatusBadge(referral.status)}
<tr class="hover:bg-portal-hover transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
{formatTimestamp(referral.createdAt)}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{referral.referrer?.name ?? 'Ukjent bruker'}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{referral.referred?.name ?? 'Ukjent bruker'}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold {statusBadge.class}"
>
{statusBadge.label}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
{formatTimestamp(referral.completedAt)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="border-portal-border flex justify-center border-t px-6 py-4">
<nav aria-label="Paginering" class="flex items-center gap-2">
{#if previousPage}
<ButtonLink
href={buildQuery({ page: previousPage })}
intent="outline"
size="sm"
class="gap-1"
>
<ChevronLeft class="h-4 w-4" />
Forrige
</ButtonLink>
{:else}
<span
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"
>
<ChevronLeft class="h-4 w-4" />
Forrige
</span>
{/if}

{#each paginationItems as item (item.type === 'page' ? item.value : item.key)}
{#if item.type === 'ellipsis'}
<span class="px-2 text-sm text-gray-400 dark:text-gray-500">…</span>
{:else if item.value === data.pagination.currentPage}
<span
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"
>
{item.value}
</span>
{:else}
<ButtonLink href={buildQuery({ page: item.value })} intent="outline" size="sm">
{item.value}
</ButtonLink>
{/if}
{/each}

{#if nextPage}
<ButtonLink
href={buildQuery({ page: nextPage })}
intent="outline"
size="sm"
class="gap-1"
>
Neste
<ChevronRight class="h-4 w-4" />
</ButtonLink>
{:else}
<span
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"
>
Neste
<ChevronRight class="h-4 w-4" />
</span>
{/if}
</nav>
</div>
{/if}
</div>
</div>
Loading