feat: improve settlement display and add split recalculation to payment edit
All checks were successful
CI / update (push) Successful in 1m13s

Settlement Display Improvements:
- Redesigned settlement cards with distinct visual style
- Added gradient background and colored top border stripe
- Centered layout with prominent amount display
- Added settlement badge with icon
- Responsive vertical layout on mobile devices
- Fixed overflow issues on small screens

Payment Edit Enhancements:
- Added automatic split recalculation when amount changes
- Implemented editable personal amounts for personal_equal split method
- Real-time validation showing total personal and remainder
- Live split preview with automatic updates
- Support for all split methods: equal, full, personal_equal, proportional
- Foreign currency support with exchange rate recalculation
- Safeguards against infinite recalculation loops
- Improved UI with split method info display
- Responsive design for mobile devices
This commit is contained in:
2026-01-13 20:03:07 +01:00
parent baa3f3e533
commit ae32db7dfd
2 changed files with 625 additions and 111 deletions

View File

@@ -145,28 +145,44 @@
{:else} {:else}
<div class="payments-grid"> <div class="payments-grid">
{#each payments as payment} {#each payments as payment}
<a href="/cospend/payments/view/{payment._id}" class="payment-card" class:settlement-card={isSettlementPayment(payment)}>
<div class="payment-header">
{#if isSettlementPayment(payment)} {#if isSettlementPayment(payment)}
<!-- Settlement Card - Distinct Layout -->
<a href="/cospend/payments/view/{payment._id}" class="payment-card settlement-card">
<div class="settlement-header">
<div class="settlement-badge">
<span class="settlement-icon">💸</span>
<span class="settlement-label">Settlement</span>
</div>
<span class="settlement-date">{formatDate(payment.date)}</span>
</div>
<div class="settlement-flow"> <div class="settlement-flow">
<div class="settlement-user-from"> <div class="settlement-user">
<ProfilePicture username={payment.paidBy} size={32} /> <ProfilePicture username={payment.paidBy} size={48} />
<span class="username">{payment.paidBy}</span> <span class="username">{payment.paidBy}</span>
</div> </div>
<div class="settlement-arrow">
<span class="arrow"></span> <div class="settlement-arrow-container">
<span class="settlement-badge-small">Settlement</span> <div class="settlement-amount-display">
{formatAmountWithCurrency(payment)}
</div> </div>
<div class="settlement-user-to"> <div class="settlement-arrow"></div>
<ProfilePicture username={getSettlementReceiver(payment)} size={32} /> </div>
<div class="settlement-user">
<ProfilePicture username={getSettlementReceiver(payment)} size={48} />
<span class="username">{getSettlementReceiver(payment)}</span> <span class="username">{getSettlementReceiver(payment)}</span>
</div> </div>
</div> </div>
<div class="settlement-amount">
<span class="amount settlement-amount-text">{formatAmountWithCurrency(payment)}</span> {#if payment.description}
<span class="date">{formatDate(payment.date)}</span> <p class="settlement-description">{payment.description}</p>
</div> {/if}
</a>
{:else} {:else}
<!-- Regular Payment Card -->
<a href="/cospend/payments/view/{payment._id}" class="payment-card">
<div class="payment-header">
<div class="payment-title-section"> <div class="payment-title-section">
<ProfilePicture username={payment.paidBy} size={40} /> <ProfilePicture username={payment.paidBy} size={40} />
<div class="payment-title"> <div class="payment-title">
@@ -184,7 +200,6 @@
{#if payment.image} {#if payment.image}
<img src={payment.image} alt="Receipt" class="receipt-thumb" /> <img src={payment.image} alt="Receipt" class="receipt-thumb" />
{/if} {/if}
{/if}
</div> </div>
{#if payment.description} {#if payment.description}
@@ -223,8 +238,8 @@
</div> </div>
</div> </div>
{/if} {/if}
</a> </a>
{/if}
{/each} {/each}
</div> </div>
@@ -413,79 +428,137 @@
} }
} }
/* Settlement Card Styles */
.settlement-card { .settlement-card {
background: linear-gradient(135deg, var(--nord6), var(--nord5)); background: linear-gradient(135deg, #e8f5e9, #f1f8e9);
border: 2px solid var(--green); 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 { .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) { @media (prefers-color-scheme: dark) {
.settlement-card { .settlement-card {
background: linear-gradient(135deg, var(--nord2), var(--nord1)); background: linear-gradient(135deg, #1a2e1a, #1e2b1e);
} }
.settlement-card:hover { .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 { .settlement-flow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; justify-content: space-between;
flex: 1; gap: 1rem;
margin-bottom: 1rem;
} }
.settlement-user-from, .settlement-user-to { .settlement-user {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex: 1;
min-width: 0;
} }
.settlement-user-from .username, .settlement-user .username {
.settlement-user-to .username { font-weight: 600;
font-weight: 500;
color: var(--green); 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 { .settlement-arrow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.settlement-arrow .arrow {
color: var(--green); color: var(--green);
font-size: 1.2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
line-height: 1;
} }
.settlement-badge-small { .settlement-description {
background: linear-gradient(135deg, var(--green), var(--lightblue)); color: var(--nord2);
color: white; margin-top: 1rem;
padding: 0.125rem 0.375rem; padding-top: 1rem;
border-radius: 0.75rem; border-top: 1px solid var(--nord4);
font-size: 0.65rem; font-style: italic;
font-weight: 500; font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.025em;
} }
.settlement-amount { @media (prefers-color-scheme: dark) {
display: flex; .settlement-description {
flex-direction: column; color: var(--nord5);
align-items: flex-end; border-top-color: var(--nord3);
gap: 0.25rem;
} }
.settlement-amount-text {
font-size: 1.1rem;
font-weight: 600;
color: var(--green);
} }
.payment-header { .payment-header {
@@ -728,23 +801,64 @@
padding: 0.75rem; padding: 0.75rem;
} }
/* Make settlement flow more compact on very small screens */ /* Make settlement more compact on small screens */
.settlement-flow { .settlement-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1rem;
} }
.settlement-user-from, .settlement-user-to { .settlement-badge {
gap: 0.25rem; padding: 0.4rem 0.75rem;
}
.settlement-user-from .username,
.settlement-user-to .username {
font-size: 0.8rem; font-size: 0.8rem;
} }
.settlement-badge-small { .settlement-icon {
font-size: 0.55rem; font-size: 1rem;
padding: 0.1rem 0.25rem; }
.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;
} }
} }
</style> </style>

View File

@@ -22,9 +22,105 @@
let exchangeRateError = $state(null); let exchangeRateError = $state(null);
let exchangeRateTimeout; let exchangeRateTimeout;
let jsEnhanced = $state(false); let jsEnhanced = $state(false);
let originalAmount = $state(null);
let categoryOptions = $derived(getCategoryOptions()); 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 () => { onMount(async () => {
jsEnhanced = true; jsEnhanced = true;
document.body.classList.add('js-loaded'); document.body.classList.add('js-loaded');
@@ -40,7 +136,25 @@
} }
const result = await response.json(); const result = await response.json();
payment = result.payment; 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) { } catch (err) {
console.error('Error loading payment:', err);
error = err.message; error = err.message;
} finally { } finally {
loading = false; loading = false;
@@ -363,12 +477,65 @@
/> />
{#if payment.splits && payment.splits.length > 0} {#if payment.splits && payment.splits.length > 0}
<FormSection title="Current Splits"> <FormSection title="Split Configuration">
<div class="split-method-info">
<span class="label">Split Method:</span>
<span class="value">
{#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}
</span>
</div>
{#if payment.splitMethod === 'personal_equal'}
<div class="personal-amounts-editor">
<h3>Personal Amounts</h3>
<p class="description">Enter personal amounts for each user. The remainder will be split equally.</p>
{#each payment.splits as split, index}
<div class="personal-input">
<label for="personal_{split.username}">{split.username}</label>
<input
id="personal_{split.username}"
type="number"
step="0.01"
min="0"
value={split.personalAmount || 0}
oninput={(e) => {
split.personalAmount = parseFloat(e.target.value) || 0;
}}
placeholder="0.00"
/>
</div>
{/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)}
<div class="remainder-info" class:error={hasError}>
<span>Total Personal: CHF {totalPersonal.toFixed(2)}</span>
<span>Remainder to Split: CHF {remainder.toFixed(2)}</span>
{#if hasError}
<div class="error-message">⚠️ Personal amounts exceed total payment amount!</div>
{/if}
</div>
{/if}
</div>
{/if}
<div class="splits-display"> <div class="splits-display">
<h3>Split Preview</h3>
{#each payment.splits as split} {#each payment.splits as split}
<div class="split-item"> <div class="split-item">
<span>{split.username}</span> <span class="split-username">{split.username}</span>
<span class:positive={split.amount < 0} class:negative={split.amount > 0}> <span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0} {#if split.amount > 0}
owes CHF {split.amount.toFixed(2)} owes CHF {split.amount.toFixed(2)}
{:else if split.amount < 0} {:else if split.amount < 0}
@@ -380,7 +547,10 @@
</div> </div>
{/each} {/each}
</div> </div>
<p class="note">Note: To modify splits, please delete and recreate the payment.</p> <p class="note">
<span class="js-only">✓ Splits recalculate automatically when you change the amount{payment.splitMethod === 'personal_equal' ? ' or personal amounts' : ''}</span>
<span class="no-js">Note: Split method and participants cannot be changed. To modify, please delete and recreate the payment.</span>
</p>
</FormSection> </FormSection>
{/if} {/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 { .splits-display {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -524,6 +851,19 @@
margin-bottom: 1rem; 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 { .split-item {
display: flex; display: flex;
justify-content: space-between; 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); color: var(--green);
font-weight: 500; font-weight: 500;
} }
.negative { .split-amount.negative {
color: var(--red); color: var(--red);
font-weight: 500; 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 { .form-actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -743,5 +1129,19 @@
.amount-currency select { .amount-currency select {
flex: none; 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;
}
} }
</style> </style>