diff --git a/src/routes/cospend/payments/+page.svelte b/src/routes/cospend/payments/+page.svelte index 1855197..b940236 100644 --- a/src/routes/cospend/payments/+page.svelte +++ b/src/routes/cospend/payments/+page.svelte @@ -145,28 +145,44 @@ {:else}
{#each payments as payment} - -
- {#if isSettlementPayment(payment)} -
-
- - {payment.paidBy} -
-
- - Settlement -
-
- - {getSettlementReceiver(payment)} -
+ {#if isSettlementPayment(payment)} + +
+
+
+ 💸 + Settlement
-
- {formatAmountWithCurrency(payment)} - {formatDate(payment.date)} + {formatDate(payment.date)} +
+ +
+
+ + {payment.paidBy}
- {:else} + +
+
+ {formatAmountWithCurrency(payment)} +
+
+
+ +
+ + {getSettlementReceiver(payment)} +
+
+ + {#if payment.description} +

{payment.description}

+ {/if} +
+ {:else} + + +
@@ -184,47 +200,46 @@ {#if payment.image} Receipt {/if} +
+ + {#if payment.description} +

{payment.description}

{/if} -
- {#if payment.description} -

{payment.description}

- {/if} - -
-
- Paid by: - {payment.paidBy} -
-
- Split: - {getSplitDescription(payment)} -
-
- - {#if payment.splits && payment.splits.length > 0} -
-

Split Details

-
- {#each payment.splits as split} -
- {split.username} - 0}> - {#if split.amount > 0} - owes {formatCurrency(split.amount, 'CHF', 'de-CH')} - {:else if split.amount < 0} - owed {formatCurrency(Math.abs(split.amount, 'CHF', 'de-CH'))} - {:else} - owes {formatCurrency(split.amount, 'CHF', 'de-CH')} - {/if} - -
- {/each} +
+
+ Paid by: + {payment.paidBy} +
+
+ Split: + {getSplitDescription(payment)}
- {/if} -
+ {#if payment.splits && payment.splits.length > 0} +
+

Split Details

+
+ {#each payment.splits as split} +
+ {split.username} + 0}> + {#if split.amount > 0} + owes {formatCurrency(split.amount, 'CHF', 'de-CH')} + {:else if split.amount < 0} + owed {formatCurrency(Math.abs(split.amount, 'CHF', 'de-CH'))} + {:else} + owes {formatCurrency(split.amount, 'CHF', 'de-CH')} + {/if} + +
+ {/each} +
+
+ {/if} + + {/if} {/each}
@@ -413,79 +428,137 @@ } } + /* Settlement Card Styles */ .settlement-card { - background: linear-gradient(135deg, var(--nord6), var(--nord5)); + background: linear-gradient(135deg, #e8f5e9, #f1f8e9); border: 2px solid var(--green); + position: relative; + overflow: hidden; + } + + .settlement-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--green), var(--lightblue)); } .settlement-card:hover { - box-shadow: 0 4px 16px rgba(163, 190, 140, 0.3); + box-shadow: 0 6px 20px rgba(163, 190, 140, 0.4); + border-color: var(--lightblue); } @media (prefers-color-scheme: dark) { .settlement-card { - background: linear-gradient(135deg, var(--nord2), var(--nord1)); + background: linear-gradient(135deg, #1a2e1a, #1e2b1e); } .settlement-card:hover { - box-shadow: 0 4px 16px rgba(163, 190, 140, 0.2); + box-shadow: 0 6px 20px rgba(163, 190, 140, 0.3); + } + } + + .settlement-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + } + + .settlement-badge { + display: flex; + align-items: center; + gap: 0.5rem; + background: linear-gradient(135deg, var(--green), var(--lightblue)); + color: white; + padding: 0.5rem 1rem; + border-radius: 2rem; + font-weight: 600; + font-size: 0.9rem; + box-shadow: 0 2px 8px rgba(163, 190, 140, 0.3); + } + + .settlement-icon { + font-size: 1.2rem; + } + + .settlement-date { + color: var(--nord3); + font-size: 0.9rem; + font-weight: 500; + } + + @media (prefers-color-scheme: dark) { + .settlement-date { + color: var(--nord4); } } .settlement-flow { display: flex; align-items: center; - gap: 0.75rem; - flex: 1; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; } - .settlement-user-from, .settlement-user-to { + .settlement-user { display: flex; + flex-direction: column; align-items: center; gap: 0.5rem; + flex: 1; + min-width: 0; } - .settlement-user-from .username, - .settlement-user-to .username { - font-weight: 500; + .settlement-user .username { + font-weight: 600; color: var(--green); + font-size: 0.95rem; + text-align: center; + word-break: break-word; + } + + .settlement-arrow-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + } + + .settlement-amount-display { + font-size: 1.3rem; + font-weight: 700; + color: var(--green); + white-space: nowrap; + text-align: center; } .settlement-arrow { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.25rem; - } - - .settlement-arrow .arrow { color: var(--green); - font-size: 1.2rem; + font-size: 2rem; font-weight: bold; + line-height: 1; } - .settlement-badge-small { - background: linear-gradient(135deg, var(--green), var(--lightblue)); - color: white; - padding: 0.125rem 0.375rem; - border-radius: 0.75rem; - font-size: 0.65rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.025em; + .settlement-description { + color: var(--nord2); + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--nord4); + font-style: italic; + font-size: 0.9rem; } - .settlement-amount { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.25rem; - } - - .settlement-amount-text { - font-size: 1.1rem; - font-weight: 600; - color: var(--green); + @media (prefers-color-scheme: dark) { + .settlement-description { + color: var(--nord5); + border-top-color: var(--nord3); + } } .payment-header { @@ -728,23 +801,64 @@ padding: 0.75rem; } - /* Make settlement flow more compact on very small screens */ - .settlement-flow { + /* Make settlement more compact on small screens */ + .settlement-header { + flex-direction: column; + align-items: flex-start; gap: 0.5rem; + margin-bottom: 1rem; } - .settlement-user-from, .settlement-user-to { - gap: 0.25rem; - } - - .settlement-user-from .username, - .settlement-user-to .username { + .settlement-badge { + padding: 0.4rem 0.75rem; font-size: 0.8rem; } - .settlement-badge-small { - font-size: 0.55rem; - padding: 0.1rem 0.25rem; + .settlement-icon { + font-size: 1rem; + } + + .settlement-date { + font-size: 0.8rem; + } + + .settlement-flow { + flex-direction: column; + gap: 1rem; + } + + .settlement-user { + width: 100%; + } + + .settlement-user .username { + font-size: 0.85rem; + } + + .settlement-arrow-container { + transform: rotate(90deg); + gap: 0.75rem; + } + + .settlement-amount-display { + font-size: 1.1rem; + transform: rotate(-90deg); + } + + .settlement-arrow { + font-size: 1.5rem; + transform: rotate(-90deg); + } + } + + /* Very small screens - simplify further */ + @media (max-width: 360px) { + .settlement-amount-display { + font-size: 1rem; + } + + .settlement-user .username { + font-size: 0.75rem; } } diff --git a/src/routes/cospend/payments/edit/[id]/+page.svelte b/src/routes/cospend/payments/edit/[id]/+page.svelte index 95a30b9..846c3a1 100644 --- a/src/routes/cospend/payments/edit/[id]/+page.svelte +++ b/src/routes/cospend/payments/edit/[id]/+page.svelte @@ -22,9 +22,105 @@ let exchangeRateError = $state(null); let exchangeRateTimeout; let jsEnhanced = $state(false); + let originalAmount = $state(null); let categoryOptions = $derived(getCategoryOptions()); + // Recalculate splits when amount changes + function recalculateSplits() { + try { + if (!payment || !payment.splits || payment.splits.length === 0) return; + + // For foreign currency, use converted amount if available, otherwise use CHF amount + let amountNum; + if (payment.currency !== 'CHF' && payment.originalAmount && convertedAmount) { + amountNum = convertedAmount; + } else { + amountNum = parseFloat(payment.amount); + } + + if (isNaN(amountNum) || amountNum <= 0) return; + + const paidBy = payment.paidBy; + const users = payment.splits.map(s => s.username); + + if (payment.splitMethod === 'equal') { + // Equal split + const splitAmount = amountNum / users.length; + payment.splits = payment.splits.map(split => ({ + ...split, + amount: split.username === paidBy ? splitAmount - amountNum : splitAmount + })); + } else if (payment.splitMethod === 'full') { + // Paid in full + const otherUsers = users.filter(u => u !== paidBy); + const amountPerOtherUser = otherUsers.length > 0 ? amountNum / otherUsers.length : 0; + payment.splits = payment.splits.map(split => ({ + ...split, + amount: split.username === paidBy ? -amountNum : amountPerOtherUser + })); + } else if (payment.splitMethod === 'personal_equal') { + // Personal + equal split + const totalPersonal = payment.splits.reduce((sum, split) => { + return sum + (split.personalAmount || 0); + }, 0); + const remainder = Math.max(0, amountNum - totalPersonal); + const equalShare = remainder / users.length; + + payment.splits = payment.splits.map(split => { + const personalAmount = split.personalAmount || 0; + const totalOwed = personalAmount + equalShare; + return { + ...split, + amount: split.username === paidBy ? totalOwed - amountNum : totalOwed + }; + }); + } else if (payment.splitMethod === 'proportional') { + // Proportional - recalculate based on stored proportions + payment.splits = payment.splits.map(split => { + const proportion = split.proportion || 0; + const splitAmount = amountNum * proportion; + return { + ...split, + amount: split.username === paidBy ? splitAmount - amountNum : splitAmount + }; + }); + } + } catch (err) { + console.error('Error recalculating splits:', err); + } + } + + // Watch for amount changes and recalculate splits + let lastCalculatedAmount = $state(null); + let lastPersonalAmounts = $state(null); + + $effect(() => { + if (!jsEnhanced || !payment || !payment.splits || payment.splits.length === 0) { + return; + } + + const currentAmount = payment.currency !== 'CHF' && payment.originalAmount && convertedAmount + ? convertedAmount + : payment.amount; + + // For personal_equal, also track personal amounts + let personalAmountsChanged = false; + if (payment.splitMethod === 'personal_equal') { + const currentPersonalAmounts = payment.splits.map(s => s.personalAmount || 0).join(','); + if (lastPersonalAmounts !== currentPersonalAmounts) { + personalAmountsChanged = true; + lastPersonalAmounts = currentPersonalAmounts; + } + } + + // Recalculate if amount changed or personal amounts changed + if ((currentAmount !== lastCalculatedAmount && currentAmount > 0) || personalAmountsChanged) { + lastCalculatedAmount = currentAmount; + recalculateSplits(); + } + }); + onMount(async () => { jsEnhanced = true; document.body.classList.add('js-loaded'); @@ -40,7 +136,25 @@ } const result = await response.json(); payment = result.payment; + + // Initialize personal amounts if undefined (for personal_equal split method) + if (payment.splitMethod === 'personal_equal' && payment.splits) { + payment.splits = payment.splits.map(split => ({ + ...split, + personalAmount: split.personalAmount || 0 + })); + } + + // Store original amount for comparison to prevent infinite recalculation + originalAmount = payment.amount; + // Set initial lastCalculatedAmount to prevent immediate recalculation on load + lastCalculatedAmount = payment.amount; + // Store initial personal amounts to prevent immediate recalculation + if (payment.splitMethod === 'personal_equal') { + lastPersonalAmounts = payment.splits.map(s => s.personalAmount || 0).join(','); + } } catch (err) { + console.error('Error loading payment:', err); error = err.message; } finally { loading = false; @@ -363,12 +477,65 @@ /> {#if payment.splits && payment.splits.length > 0} - + +
+ Split Method: + + {#if payment.splitMethod === 'equal'} + Equal Split + {:else if payment.splitMethod === 'full'} + Paid in Full + {:else if payment.splitMethod === 'personal_equal'} + Personal + Equal Split + {:else if payment.splitMethod === 'proportional'} + Custom Proportions + {:else} + {payment.splitMethod} + {/if} + +
+ + {#if payment.splitMethod === 'personal_equal'} +
+

Personal Amounts

+

Enter personal amounts for each user. The remainder will be split equally.

+ {#each payment.splits as split, index} +
+ + { + split.personalAmount = parseFloat(e.target.value) || 0; + }} + placeholder="0.00" + /> +
+ {/each} + {#if payment.amount} + {@const totalPersonal = payment.splits.reduce((sum, s) => sum + (s.personalAmount || 0), 0)} + {@const remainder = Math.max(0, parseFloat(payment.amount) - totalPersonal)} + {@const hasError = totalPersonal > parseFloat(payment.amount)} +
+ Total Personal: CHF {totalPersonal.toFixed(2)} + Remainder to Split: CHF {remainder.toFixed(2)} + {#if hasError} +
⚠️ Personal amounts exceed total payment amount!
+ {/if} +
+ {/if} +
+ {/if} +
+

Split Preview

{#each payment.splits as split}
- {split.username} - 0}> + {split.username} + 0}> {#if split.amount > 0} owes CHF {split.amount.toFixed(2)} {:else if split.amount < 0} @@ -380,7 +547,10 @@
{/each}
-

Note: To modify splits, please delete and recreate the payment.

+

+ ✓ Splits recalculate automatically when you change the amount{payment.splitMethod === 'personal_equal' ? ' or personal amounts' : ''} + Note: Split method and participants cannot be changed. To modify, please delete and recreate the payment. +

{/if} @@ -517,6 +687,163 @@ } } + .split-method-info { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding: 0.75rem; + background-color: var(--nord14); + border-radius: 0.5rem; + border: 1px solid var(--green); + } + + @media (prefers-color-scheme: dark) { + .split-method-info { + background-color: var(--nord2); + border-color: var(--nord3); + } + } + + .split-method-info .label { + font-weight: 600; + color: var(--nord1); + } + + .split-method-info .value { + color: var(--green); + font-weight: 500; + } + + @media (prefers-color-scheme: dark) { + .split-method-info .label { + color: var(--nord5); + } + } + + .personal-amounts-editor { + margin-bottom: 1.5rem; + padding: 1rem; + background-color: var(--nord5); + border-radius: 0.5rem; + border: 1px solid var(--nord4); + } + + @media (prefers-color-scheme: dark) { + .personal-amounts-editor { + background-color: var(--nord2); + border-color: var(--nord3); + } + } + + .personal-amounts-editor h3 { + margin-top: 0; + margin-bottom: 0.5rem; + color: var(--nord0); + font-size: 1rem; + } + + @media (prefers-color-scheme: dark) { + .personal-amounts-editor h3 { + color: var(--font-default-dark); + } + } + + .personal-amounts-editor .description { + color: var(--nord2); + font-size: 0.9rem; + margin-bottom: 1rem; + font-style: italic; + } + + @media (prefers-color-scheme: dark) { + .personal-amounts-editor .description { + color: var(--nord4); + } + } + + .personal-input { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.75rem; + } + + .personal-input label { + min-width: 120px; + margin-bottom: 0; + font-weight: 500; + } + + .personal-input input { + max-width: 150px; + padding: 0.5rem; + border: 1px solid var(--nord4); + border-radius: 0.5rem; + font-size: 1rem; + background-color: var(--nord6); + color: var(--nord0); + } + + .personal-input input:focus { + outline: none; + border-color: var(--blue); + box-shadow: 0 0 0 2px rgba(94, 129, 172, 0.2); + } + + @media (prefers-color-scheme: dark) { + .personal-input input { + background-color: var(--nord1); + color: var(--font-default-dark); + border-color: var(--nord3); + } + } + + .remainder-info { + margin-top: 1rem; + padding: 0.75rem; + background-color: var(--nord14); + border-radius: 0.5rem; + border: 1px solid var(--green); + } + + .remainder-info.error { + background-color: var(--nord6); + border-color: var(--red); + } + + @media (prefers-color-scheme: dark) { + .remainder-info { + background-color: var(--nord1); + border-color: var(--nord3); + } + + .remainder-info.error { + background-color: var(--accent-dark); + border-color: var(--red); + } + } + + .remainder-info span { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--nord0); + } + + @media (prefers-color-scheme: dark) { + .remainder-info span { + color: var(--font-default-dark); + } + } + + .error-message { + color: var(--red); + font-weight: 600; + margin-top: 0.5rem; + font-size: 0.9rem; + } + .splits-display { display: flex; flex-direction: column; @@ -524,6 +851,19 @@ margin-bottom: 1rem; } + .splits-display h3 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--nord0); + font-size: 1rem; + } + + @media (prefers-color-scheme: dark) { + .splits-display h3 { + color: var(--font-default-dark); + } + } + .split-item { display: flex; justify-content: space-between; @@ -541,12 +881,42 @@ } } - .positive { + .split-username { + font-weight: 500; + color: var(--nord0); + } + + @media (prefers-color-scheme: dark) { + .split-username { + color: var(--font-default-dark); + } + } + + .split-details { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + } + + .personal-amount { + font-size: 0.85rem; + color: var(--nord3); + font-style: italic; + } + + @media (prefers-color-scheme: dark) { + .personal-amount { + color: var(--nord4); + } + } + + .split-amount.positive { color: var(--green); font-weight: 500; } - .negative { + .split-amount.negative { color: var(--red); font-weight: 500; } @@ -564,6 +934,22 @@ } } + .js-only { + display: none; + } + + .no-js { + display: inline; + } + + :global(body.js-loaded) .js-only { + display: inline; + } + + :global(body.js-loaded) .no-js { + display: none; + } + .form-actions { display: flex; justify-content: space-between; @@ -743,5 +1129,19 @@ .amount-currency select { flex: none; } + + .personal-input { + flex-direction: column; + align-items: flex-start; + } + + .personal-input label { + min-width: auto; + } + + .personal-input input { + width: 100%; + max-width: none; + } } \ No newline at end of file