|
| 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