saved cards store

This commit is contained in:
2026-06-07 12:38:22 +01:00
parent 75cca8b158
commit 6756723c60
@@ -83,7 +83,9 @@
let isGenerateValid = $derived(generateAmount && !generateError);
let isTopUpValid = $derived(topUpAmount && !topUpError);
let isTransferValid = $derived(transferAmount && !transferAmountError && transferToCode && !transferCodeError);
let isTransferValid = $derived(
transferAmount && !transferAmountError && transferToCode && !transferCodeError
);
// Choice flow state
let generateStep = $state<'choice' | 'amount'>('choice');
@@ -234,63 +236,28 @@
topUpAmount = '';
}
async function handleTillPaymentComplete(result: { id: string; total_amount: number; payment_method: string; status: string }) {
async function handleTillPaymentComplete(result: {
id: string;
item_id?: string;
total_amount: number;
payment_method: string;
status: string;
}) {
// The till sale endpoint already created/topped-up the gift card atomically.
// All we need to do here is show confirmation and refresh the list.
if (tillAction === 'create') {
if (!isGenerateValid) return;
creating = true;
try {
const res = await fetch('/api/admin/gift-cards', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({ amount: Number(generateAmount) })
});
if (res.ok) {
const data = await res.json();
toast.success(`Gift card ${formatCardCode(data.id)} generated successfully!`);
showTillPayment = false;
showGenerateModal = false;
resetGenerateModal();
await fetchGiftCards();
} else {
const errText = await res.text();
toast.error(errText || 'Failed to generate gift card');
}
} catch (err) {
toast.error('Network error generating gift card');
} finally {
creating = false;
}
const code = result.item_id ? formatCardCode(result.item_id) : 'unknown';
toast.success(`Gift card ${code} generated successfully!`);
showTillPayment = false;
showGenerateModal = false;
resetGenerateModal();
} else {
if (!isTopUpValid || !selectedCardId) return;
toppingUp = true;
try {
const res = await fetch(`/api/admin/gift-cards/${selectedCardId}/topup`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({ amount: Number(topUpAmount) })
});
if (res.ok) {
toast.success('Gift card topped up successfully');
showTillPayment = false;
showTopUpModal = false;
resetTopUpModal();
await fetchGiftCards();
} else {
const errText = await res.text();
toast.error(errText || 'Failed to top up gift card');
}
} catch (err) {
toast.error('Network error topping up gift card');
} finally {
toppingUp = false;
}
toast.success('Gift card topped up successfully');
showTillPayment = false;
showTopUpModal = false;
resetTopUpModal();
}
await fetchGiftCards();
}
function handleCodeInput(e: Event) {
@@ -322,7 +289,16 @@
}
// =============== Sorting ===============
type SortKey = 'code' | 'added' | 'remaining' | 'created' | 'status' | 'name' | 'email' | 'balance' | 'updated';
type SortKey =
| 'code'
| 'added'
| 'remaining'
| 'created'
| 'status'
| 'name'
| 'email'
| 'balance'
| 'updated';
let cardSort = $state<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'created', dir: 'desc' });
let balanceSort = $state<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'updated', dir: 'desc' });
@@ -351,16 +327,21 @@
const mul = dir === 'asc' ? 1 : -1;
cards.sort((a, b) => {
switch (key) {
case 'code': return a.id.localeCompare(b.id) * mul;
case 'added': return (a.total_funds_added - b.total_funds_added) * mul;
case 'remaining': return (a.amount_remaining - b.amount_remaining) * mul;
case 'created': return (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * mul;
case 'code':
return a.id.localeCompare(b.id) * mul;
case 'added':
return (a.total_funds_added - b.total_funds_added) * mul;
case 'remaining':
return (a.amount_remaining - b.amount_remaining) * mul;
case 'created':
return (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * mul;
case 'status': {
const aVal = a.redeemed_by ? 2 : a.amount_remaining === 0 ? 1 : 0;
const bVal = b.redeemed_by ? 2 : b.amount_remaining === 0 ? 1 : 0;
return (aVal - bVal) * mul;
}
default: return 0;
default:
return 0;
}
});
return cards;
@@ -372,11 +353,16 @@
const mul = dir === 'asc' ? 1 : -1;
bals.sort((a, b) => {
switch (key) {
case 'name': return a.name.localeCompare(b.name) * mul;
case 'email': return a.email.localeCompare(b.email) * mul;
case 'balance': return (a.balance - b.balance) * mul;
case 'updated': return (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) * mul;
default: return 0;
case 'name':
return a.name.localeCompare(b.name) * mul;
case 'email':
return a.email.localeCompare(b.email) * mul;
case 'balance':
return (a.balance - b.balance) * mul;
case 'updated':
return (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) * mul;
default:
return 0;
}
});
return bals;
@@ -403,9 +389,7 @@
Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred.
</Card.Description>
</div>
<Button onclick={() => showGenerateModal = true}>
Generate Gift Card
</Button>
<Button onclick={() => (showGenerateModal = true)}>Generate Gift Card</Button>
</div>
</Card.Header>
@@ -435,19 +419,20 @@
<div class="flex border-b border-gray-200">
<button
type="button"
class="px-4 py-2 text-sm font-medium transition-colors border-b-2 {activeSection === 'cards'
? 'border-primary text-primary font-semibold'
class="border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeSection === 'cards'
? 'border-primary font-semibold text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeSection = 'cards'}
onclick={() => (activeSection = 'cards')}
>
Physical Gift Cards ({summary.gift_cards.length})
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium transition-colors border-b-2 {activeSection === 'balances'
? 'border-primary text-primary font-semibold'
class="border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeSection ===
'balances'
? 'border-primary font-semibold text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeSection = 'balances'}
onclick={() => (activeSection = 'balances')}
>
Customer Account Balances ({summary.user_balances.length})
</button>
@@ -457,23 +442,38 @@
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs text-gray-500 uppercase tracking-wider">
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('code')}>
<tr class="border-b text-left text-xs tracking-wider text-gray-500 uppercase">
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleCardSort('code')}
>
Card Code{sortArrow('code', cardSort)}
</th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('added')}>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleCardSort('added')}
>
Total Added{sortArrow('added', cardSort)}
</th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('remaining')}>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleCardSort('remaining')}
>
Remaining{sortArrow('remaining', cardSort)}
</th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('created')}>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleCardSort('created')}
>
Created On{sortArrow('created', cardSort)}
</th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleCardSort('status')}>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleCardSort('status')}
>
Status{sortArrow('status', cardSort)}
</th>
<th class="py-3 font-medium text-center">Actions</th>
<th class="py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody>
@@ -498,20 +498,30 @@
{#each sortedCards as gc (gc.id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</td>
<td class="py-3 font-medium text-gray-600">{formatCurrency(gc.total_funds_added)}</td>
<td class="py-3 font-semibold text-card-foreground">{formatCurrency(gc.amount_remaining)}</td>
<td class="py-3 font-medium text-gray-600"
>{formatCurrency(gc.total_funds_added)}</td
>
<td class="py-3 font-semibold text-card-foreground"
>{formatCurrency(gc.amount_remaining)}</td
>
<td class="py-3 text-gray-600">{formatDate(gc.created_at)}</td>
<td class="py-3">
{#if gc.redeemed_by}
<span class="inline-flex items-center rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 border border-green-200">
<span
class="inline-flex items-center rounded-full border border-green-200 bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700"
>
Claimed
</span>
{:else if gc.amount_remaining === 0}
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
<span
class="inline-flex items-center rounded-full border border-gray-200 bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"
>
Spent
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
<span
class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-600"
>
Active
</span>
{/if}
@@ -555,7 +565,7 @@
<div class="grid gap-4 md:hidden">
{#if loading}
{#each Array(2) as _, i (i)}
<div class="rounded-lg border p-4 space-y-3">
<div class="space-y-3 rounded-lg border p-4">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-24" />
@@ -567,35 +577,45 @@
</div>
{:else}
{#each sortedCards as gc (gc.id)}
<div class="rounded-lg border p-4 space-y-3 hover:bg-gray-50">
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</span>
{#if gc.redeemed_by}
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 border border-green-200">
<span
class="inline-flex items-center rounded-full border border-green-200 bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700"
>
Claimed
</span>
{:else if gc.amount_remaining === 0}
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 border border-gray-200">
<span
class="inline-flex items-center rounded-full border border-gray-200 bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
>
Spent
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700 border border-blue-200">
<span
class="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700"
>
Active
</span>
{/if}
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-600 border-t border-b py-2">
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
<div>
<span class="text-gray-400">Total Added:</span>
<span class="font-semibold text-gray-700 ml-1">{formatCurrency(gc.total_funds_added)}</span>
<span class="ml-1 font-semibold text-gray-700"
>{formatCurrency(gc.total_funds_added)}</span
>
</div>
<div>
<span class="text-gray-400">Remaining:</span>
<span class="font-semibold text-gray-700 ml-1">{formatCurrency(gc.amount_remaining)}</span>
<span class="ml-1 font-semibold text-gray-700"
>{formatCurrency(gc.amount_remaining)}</span
>
</div>
<div class="col-span-2">
<span class="text-gray-400">Created:</span>
<span class="font-medium text-gray-700 ml-1">{formatDate(gc.created_at)}</span>
<span class="ml-1 font-medium text-gray-700">{formatDate(gc.created_at)}</span>
</div>
</div>
<div class="flex justify-end gap-2 pt-1">
@@ -633,17 +653,29 @@
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs text-gray-500 uppercase tracking-wider">
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('name')}>
<tr class="border-b text-left text-xs tracking-wider text-gray-500 uppercase">
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleBalanceSort('name')}
>
Customer{sortArrow('name', balanceSort)}
</th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('email')}>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleBalanceSort('email')}
>
Email{sortArrow('email', balanceSort)}
</th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('balance')}>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleBalanceSort('balance')}
>
Account Balance{sortArrow('balance', balanceSort)}
</th>
<th class="py-3 font-medium cursor-pointer hover:text-foreground select-none" onclick={() => toggleBalanceSort('updated')}>
<th
class="cursor-pointer py-3 font-medium select-none hover:text-foreground"
onclick={() => toggleBalanceSort('updated')}
>
Last Updated{sortArrow('updated', balanceSort)}
</th>
</tr>
@@ -682,7 +714,7 @@
<div class="grid gap-4 md:hidden">
{#if loading}
{#each Array(2) as _, i (i)}
<div class="rounded-lg border p-4 space-y-3">
<div class="space-y-3 rounded-lg border p-4">
<Skeleton class="h-4 w-36" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-24" />
@@ -694,22 +726,22 @@
</div>
{:else}
{#each sortedBalances as ub (ub.user_id)}
<div class="rounded-lg border p-4 space-y-3 hover:bg-gray-50">
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900">{ub.name}</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-600 border-t border-b py-2">
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
<div class="col-span-2">
<span class="text-gray-400">Email:</span>
<span class="font-medium text-gray-700 ml-1">{ub.email}</span>
<span class="ml-1 font-medium text-gray-700">{ub.email}</span>
</div>
<div>
<span class="text-gray-400">Balance:</span>
<span class="font-bold text-primary ml-1">{formatCurrency(ub.balance)}</span>
<span class="ml-1 font-bold text-primary">{formatCurrency(ub.balance)}</span>
</div>
<div>
<span class="text-gray-400">Updated:</span>
<span class="font-medium text-gray-700 ml-1">{formatDate(ub.updated_at)}</span>
<span class="ml-1 font-medium text-gray-700">{formatDate(ub.updated_at)}</span>
</div>
</div>
</div>
@@ -734,22 +766,32 @@
<button
type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-gray-50"
onclick={() => { generateMode = 'giveaway'; generateStep = 'amount'; }}
onclick={() => {
generateMode = 'giveaway';
generateStep = 'amount';
}}
>
<div class="font-semibold text-card-foreground">Giveaway (On the House)</div>
<div class="mt-1 text-sm text-muted-foreground">Free promotional card, no payment needed</div>
<div class="mt-1 text-sm text-muted-foreground">
Free promotional card, no payment needed
</div>
</button>
<button
type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-gray-50"
onclick={() => { generateMode = 'purchase'; generateStep = 'amount'; }}
onclick={() => {
generateMode = 'purchase';
generateStep = 'amount';
}}
>
<div class="font-semibold text-card-foreground">Customer Purchase</div>
<div class="mt-1 text-sm text-muted-foreground">Collect payment via cash or card machine</div>
<div class="mt-1 text-sm text-muted-foreground">
Collect payment via cash or card machine
</div>
</button>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => showGenerateModal = false}>Cancel</Button>
<Button variant="ghost" onclick={() => (showGenerateModal = false)}>Cancel</Button>
</Modal.Footer>
{:else}
<div class="space-y-4 py-4">
@@ -763,25 +805,19 @@
bind:value={generateAmount}
/>
{#if generateError}
<span class="text-xs text-red-500 font-medium">{generateError}</span>
<span class="text-xs font-medium text-red-500">{generateError}</span>
{/if}
</div>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => generateStep = 'choice'}>Back</Button>
<Button variant="ghost" onclick={() => (generateStep = 'choice')}>Back</Button>
{#if generateMode === 'giveaway'}
<Button
onclick={generateGiftCard}
disabled={creating || !isGenerateValid}
>
<Button onclick={generateGiftCard} disabled={creating || !isGenerateValid}>
{creating ? 'Generating...' : 'Generate Card'}
</Button>
{:else}
<Button
onclick={goToGeneratePayment}
disabled={!isGenerateValid}
>
<Button onclick={goToGeneratePayment} disabled={!isGenerateValid}>
Continue to Payment
</Button>
{/if}
@@ -795,7 +831,11 @@
<Modal.Content class="max-w-md">
<Modal.Header>
<Modal.Title>Top Up Gift Card</Modal.Title>
<Modal.Description>Add additional funds to active, unclaimed gift card {formatCardCode(selectedCardId ?? '')}.</Modal.Description>
<Modal.Description
>Add additional funds to active, unclaimed gift card {formatCardCode(
selectedCardId ?? ''
)}.</Modal.Description
>
</Modal.Header>
{#if topUpStep === 'choice'}
@@ -804,7 +844,10 @@
<button
type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-gray-50"
onclick={() => { topUpMode = 'giveaway'; topUpStep = 'amount'; }}
onclick={() => {
topUpMode = 'giveaway';
topUpStep = 'amount';
}}
>
<div class="font-semibold text-card-foreground">Giveaway (On the House)</div>
<div class="mt-1 text-sm text-muted-foreground">Free top-up, no payment needed</div>
@@ -812,14 +855,19 @@
<button
type="button"
class="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:bg-gray-50"
onclick={() => { topUpMode = 'purchase'; topUpStep = 'amount'; }}
onclick={() => {
topUpMode = 'purchase';
topUpStep = 'amount';
}}
>
<div class="font-semibold text-card-foreground">Customer Purchase</div>
<div class="mt-1 text-sm text-muted-foreground">Collect payment via cash or card machine</div>
<div class="mt-1 text-sm text-muted-foreground">
Collect payment via cash or card machine
</div>
</button>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => showTopUpModal = false}>Cancel</Button>
<Button variant="ghost" onclick={() => (showTopUpModal = false)}>Cancel</Button>
</Modal.Footer>
{:else}
<div class="space-y-4 py-4">
@@ -833,27 +881,19 @@
bind:value={topUpAmount}
/>
{#if topUpError}
<span class="text-xs text-red-500 font-medium">{topUpError}</span>
<span class="text-xs font-medium text-red-500">{topUpError}</span>
{/if}
</div>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => topUpStep = 'choice'}>Back</Button>
<Button variant="ghost" onclick={() => (topUpStep = 'choice')}>Back</Button>
{#if topUpMode === 'giveaway'}
<Button
onclick={topUpCard}
disabled={toppingUp || !isTopUpValid}
>
<Button onclick={topUpCard} disabled={toppingUp || !isTopUpValid}>
{toppingUp ? 'Topping Up...' : 'Add Funds'}
</Button>
{:else}
<Button
onclick={goToTopUpPayment}
disabled={!isTopUpValid}
>
Continue to Payment
</Button>
<Button onclick={goToTopUpPayment} disabled={!isTopUpValid}>Continue to Payment</Button>
{/if}
</Modal.Footer>
{/if}
@@ -865,7 +905,9 @@
<Modal.Content class="max-w-md">
<Modal.Header>
<Modal.Title>Transfer Balance</Modal.Title>
<Modal.Description>Transfer funds from {formatCardCode(selectedCardId ?? '')} directly to another card.</Modal.Description>
<Modal.Description
>Transfer funds from {formatCardCode(selectedCardId ?? '')} directly to another card.</Modal.Description
>
</Modal.Header>
<div class="space-y-4 py-4">
@@ -880,7 +922,7 @@
oninput={handleCodeInput}
/>
{#if transferCodeError}
<span class="text-xs text-red-500 font-medium">{transferCodeError}</span>
<span class="text-xs font-medium text-red-500">{transferCodeError}</span>
{/if}
</div>
<div class="space-y-2">
@@ -893,17 +935,14 @@
bind:value={transferAmount}
/>
{#if transferAmountError}
<span class="text-xs text-red-500 font-medium">{transferAmountError}</span>
<span class="text-xs font-medium text-red-500">{transferAmountError}</span>
{/if}
</div>
</div>
<Modal.Footer>
<Button variant="outline" onclick={() => showTransferModal = false}>Cancel</Button>
<Button
onclick={transferCard}
disabled={transferring || !isTransferValid}
>
<Button variant="outline" onclick={() => (showTransferModal = false)}>Cancel</Button>
<Button onclick={transferCard} disabled={transferring || !isTransferValid}>
{transferring ? 'Transferring...' : 'Transfer Balance'}
</Button>
</Modal.Footer>
@@ -916,7 +955,9 @@
itemType="gift_card"
action={tillAction}
giftCardId={tillGiftCardId}
onClose={() => { showTillPayment = false; }}
onClose={() => {
showTillPayment = false;
}}
onComplete={handleTillPaymentComplete}
/>
{/if}
{/if}