diff --git a/src/lib/components/FormSection.svelte b/src/lib/components/FormSection.svelte new file mode 100644 index 0000000..11624fe --- /dev/null +++ b/src/lib/components/FormSection.svelte @@ -0,0 +1,38 @@ + + +
+ {#if title} +

{title}

+ {/if} + +
+ + \ No newline at end of file diff --git a/src/lib/components/ImageUpload.svelte b/src/lib/components/ImageUpload.svelte new file mode 100644 index 0000000..50887c3 --- /dev/null +++ b/src/lib/components/ImageUpload.svelte @@ -0,0 +1,247 @@ + + +
+

{title}

+ + {#if currentImage} +
+ Receipt +
+ +
+
+ {/if} + + {#if imagePreview} +
+ Receipt preview + +
+ {:else} +
+ + +
+ {/if} + + {#if uploading} +
Uploading image...
+ {/if} +
+ + \ No newline at end of file diff --git a/src/lib/components/SplitMethodSelector.svelte b/src/lib/components/SplitMethodSelector.svelte new file mode 100644 index 0000000..4be72dd --- /dev/null +++ b/src/lib/components/SplitMethodSelector.svelte @@ -0,0 +1,456 @@ + + +
+

Split Method

+ +
+ + +
+ + {#if splitMethod === 'proportional'} +
+

Custom Split Amounts

+ {#each users as user} +
+ + +
+ {/each} +
+ {/if} + + {#if splitMethod === 'personal_equal'} +
+

Personal Amounts

+

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

+ {#each users as user} +
+ + +
+ {/each} + {#if amount} +
+ Total Personal: CHF {Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0).toFixed(2)} + Remainder to Split: CHF {Math.max(0, parseFloat(amount) - Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0)).toFixed(2)} + {#if personalTotalError} +
⚠️ Personal amounts exceed total payment amount!
+ {/if} +
+ {/if} +
+ {/if} + + {#if Object.keys(splitAmounts).length > 0} +
+

Split Preview

+ {#each users as user} +
+
+ + {user} +
+ 0}> + {#if splitAmounts[user] > 0} + owes CHF {splitAmounts[user].toFixed(2)} + {:else if splitAmounts[user] < 0} + is owed CHF {Math.abs(splitAmounts[user]).toFixed(2)} + {:else} + owes CHF {splitAmounts[user].toFixed(2)} + {/if} + +
+ {/each} +
+ {/if} +
+ + \ No newline at end of file diff --git a/src/lib/components/UsersList.svelte b/src/lib/components/UsersList.svelte new file mode 100644 index 0000000..6d0c8b5 --- /dev/null +++ b/src/lib/components/UsersList.svelte @@ -0,0 +1,239 @@ + + +
+

Split Between Users

+ + {#if predefinedMode} +
+

Splitting between predefined users:

+
+ {#each users as user} +
+ + {user} + {#if user === currentUser} + You + {/if} +
+ {/each} +
+
+ {:else} +
+ {#each users as user} +
+ + {user} + {#if user === currentUser} + You + {/if} + {#if canRemoveUsers && user !== currentUser} + + {/if} +
+ {/each} +
+ + + {/if} +
+ + \ No newline at end of file diff --git a/src/routes/cospend/payments/+page.svelte b/src/routes/cospend/payments/+page.svelte index 17ea682..61c4c8a 100644 --- a/src/routes/cospend/payments/+page.svelte +++ b/src/routes/cospend/payments/+page.svelte @@ -219,25 +219,6 @@ {/if} -
- Created by {payment.createdBy} - {#if payment.createdBy === data.session.user.nickname} -
- - -
- {/if} -
{/each} @@ -678,73 +659,6 @@ } } - .payment-actions { - display: flex; - justify-content: space-between; - align-items: center; - border-top: 1px solid var(--nord4); - padding-top: 1rem; - } - - .created-by { - font-size: 0.9rem; - color: var(--nord3); - } - - @media (prefers-color-scheme: dark) { - .payment-actions { - border-top-color: var(--nord2); - } - - .created-by { - color: var(--nord4); - } - } - - .action-buttons { - display: flex; - gap: 0.5rem; - } - - .btn-edit, .btn-delete { - padding: 0.5rem 0.75rem; - border-radius: 0.25rem; - border: none; - cursor: pointer; - font-size: 0.9rem; - transition: all 0.2s; - } - - .btn-edit { - background-color: var(--nord5); - color: var(--nord0); - border: 1px solid var(--nord4); - } - - .btn-edit:hover { - background-color: var(--nord4); - } - - .btn-delete { - background-color: var(--red); - color: white; - } - - .btn-delete:hover { - background-color: var(--nord11); - } - - @media (prefers-color-scheme: dark) { - .btn-edit { - background-color: var(--nord2); - color: var(--font-default-dark); - border-color: var(--nord3); - } - - .btn-edit:hover { - background-color: var(--nord3); - } - } .pagination { display: flex; @@ -781,10 +695,5 @@ grid-template-columns: 1fr; } - .payment-actions { - flex-direction: column; - align-items: flex-start; - gap: 1rem; - } } diff --git a/src/routes/cospend/payments/add/+page.svelte b/src/routes/cospend/payments/add/+page.svelte index 0ed3e1a..e9f1e37 100644 --- a/src/routes/cospend/payments/add/+page.svelte +++ b/src/routes/cospend/payments/add/+page.svelte @@ -6,6 +6,9 @@ import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users'; import { validateCronExpression, getFrequencyDescription, calculateNextExecutionDate } from '$lib/utils/recurring'; import ProfilePicture from '$lib/components/ProfilePicture.svelte'; + import SplitMethodSelector from '$lib/components/SplitMethodSelector.svelte'; + import UsersList from '$lib/components/UsersList.svelte'; + import ImageUpload from '$lib/components/ImageUpload.svelte'; export let data; export let form; @@ -33,12 +36,12 @@ let imageFile = null; let imagePreview = ''; + let uploading = false; let newUser = ''; let splitAmounts = {}; let personalAmounts = {}; let loading = false; let error = form?.error || null; - let personalTotalError = false; let predefinedMode = data.predefinedUsers.length > 0; let jsEnhanced = false; let cronError = false; @@ -109,54 +112,19 @@ } }); - function handleImageChange(event) { - const file = event.target.files[0]; - if (file) { - if (file.size > 5 * 1024 * 1024) { - alert('File size must be less than 5MB'); - return; - } - - const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; - if (!allowedTypes.includes(file.type)) { - alert('Please select a valid image file (JPEG, PNG, WebP)'); - return; - } - - imageFile = file; - const reader = new FileReader(); - reader.onload = (e) => { - imagePreview = e.target.result; - }; - reader.readAsDataURL(file); - } + function handleImageSelected(event) { + imageFile = event.detail; } - function removeImage() { + function handleImageError(event) { + error = event.detail; + } + + function handleImageRemoved() { imageFile = null; imagePreview = ''; } - function addUser() { - if (predefinedMode) return; // No adding users in predefined mode - - if (newUser.trim() && !users.includes(newUser.trim())) { - users = [...users, newUser.trim()]; - addSplitForUser(newUser.trim()); - newUser = ''; - } - } - - function removeUser(userToRemove) { - if (predefinedMode) return; // No removing users in predefined mode - - if (users.length > 1 && userToRemove !== data.session.user.nickname) { - users = users.filter(u => u !== userToRemove); - delete splitAmounts[userToRemove]; - splitAmounts = { ...splitAmounts }; - } - } - function addSplitForUser(username) { if (!splitAmounts[username]) { splitAmounts[username] = 0; @@ -164,89 +132,10 @@ } } - function calculateEqualSplits() { - if (!formData.amount || users.length === 0) return; - - const amountNum = parseFloat(formData.amount); - const splitAmount = amountNum / users.length; - - users.forEach(user => { - if (user === formData.paidBy) { - splitAmounts[user] = splitAmount - amountNum; // They get negative (they're owed) - } else { - splitAmounts[user] = splitAmount; // They owe positive amount - } - }); - splitAmounts = { ...splitAmounts }; - } - - function calculateFullPayment() { - if (!formData.amount) return; - - const amountNum = parseFloat(formData.amount); - const otherUsers = users.filter(user => user !== formData.paidBy); - const amountPerOtherUser = otherUsers.length > 0 ? amountNum / otherUsers.length : 0; - - users.forEach(user => { - if (user === formData.paidBy) { - splitAmounts[user] = -amountNum; // They paid it all, so they're owed the full amount - } else { - splitAmounts[user] = amountPerOtherUser; // Others owe their share of the full amount - } - }); - splitAmounts = { ...splitAmounts }; - } - - function calculatePersonalEqualSplit() { - if (!formData.amount || users.length === 0) return; - - const totalAmount = parseFloat(formData.amount); - - // Calculate total personal amounts - const totalPersonal = users.reduce((sum, user) => { - return sum + (parseFloat(personalAmounts[user]) || 0); - }, 0); - - // Remaining amount to be split equally - const remainder = Math.max(0, totalAmount - totalPersonal); - const equalShare = remainder / users.length; - - users.forEach(user => { - const personalAmount = parseFloat(personalAmounts[user]) || 0; - const totalOwed = personalAmount + equalShare; - - if (user === formData.paidBy) { - // Person who paid gets back what others owe minus what they personally used - splitAmounts[user] = totalOwed - totalAmount; - } else { - // Others owe their personal amount + equal share - splitAmounts[user] = totalOwed; - } - }); - splitAmounts = { ...splitAmounts }; - } - - function handleSplitMethodChange() { - if (formData.splitMethod === 'equal') { - calculateEqualSplits(); - } else if (formData.splitMethod === 'full') { - calculateFullPayment(); - } else if (formData.splitMethod === 'personal_equal') { - calculatePersonalEqualSplit(); - } else if (formData.splitMethod === 'proportional') { - // For proportional, user enters amounts manually - just ensure all users have entries - users.forEach(user => { - if (!(user in splitAmounts)) { - splitAmounts[user] = 0; - } - }); - splitAmounts = { ...splitAmounts }; - } - } - async function uploadImage() { if (!imageFile) return null; + uploading = true; const formData = new FormData(); formData.append('image', imageFile); @@ -265,6 +154,8 @@ } catch (err) { console.error('Image upload failed:', err); return null; + } finally { + uploading = false; } } @@ -274,16 +165,6 @@ return; } - // Validate personal amounts for personal_equal split - if (formData.splitMethod === 'personal_equal') { - const totalPersonal = Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0); - const totalAmount = parseFloat(formData.amount); - - if (totalPersonal > totalAmount) { - error = 'Personal amounts cannot exceed the total payment amount'; - return; - } - } if (users.length === 0) { error = 'Please add at least one user to split with'; @@ -336,20 +217,6 @@ } } - $: if (formData.amount && formData.splitMethod && formData.paidBy) { - handleSplitMethodChange(); - } - - // Validate and recalculate when personal amounts change - $: if (formData.splitMethod === 'personal_equal' && personalAmounts && formData.amount) { - const totalPersonal = Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0); - const totalAmount = parseFloat(formData.amount); - personalTotalError = totalPersonal > totalAmount; - - if (!personalTotalError) { - calculatePersonalEqualSplit(); - } - } function validateCron() { if (recurringData.frequency !== 'custom') { @@ -569,189 +436,54 @@ {/if} -
-

Receipt Image

- - {#if imagePreview} -
- Receipt preview - -
- {:else} -
- - -
- {/if} + + + + + +
+

Enter users to split with (one per line):

+
-
-

Split Between Users

- - {#if predefinedMode} -
-

Splitting between predefined users:

-
- {#each users as user} -
- - {user} - {#if user === data.session?.user?.nickname} - You - {/if} -
- {/each} -
-
- {:else} -
- {#each users as user} -
- - {user} - {#if user !== data.session.user.nickname} - - {/if} -
- {/each} -
+ + {#if predefinedMode} + {#each data.predefinedUsers as user, i} + + {/each} + {:else} + {#each users as user, i} + + {/each} + {/if} - - - -
-

Enter users to split with (one per line):

- -
- {/if} - - - {#if predefinedMode} - {#each data.predefinedUsers as user, i} - - {/each} - {:else} - {#each users as user, i} - - {/each} - {/if} -
- -
-

Split Method

- -
- - -
- - {#if formData.splitMethod === 'proportional'} -
-

Custom Split Amounts

- {#each users as user} -
- - -
- {/each} -
- {/if} - - {#if formData.splitMethod === 'personal_equal'} -
-

Personal Amounts

-

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

- {#each users as user} -
- - -
- {/each} - {#if formData.amount} -
- Total Personal: CHF {Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0).toFixed(2)} - Remainder to Split: CHF {Math.max(0, parseFloat(formData.amount) - Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0)).toFixed(2)} - {#if personalTotalError} -
⚠️ Personal amounts exceed total payment amount!
- {/if} -
- {/if} -
- {/if} - - {#if Object.keys(splitAmounts).length > 0} -
-

Split Preview

- {#each users as user} -
-
- - {user} -
- 0}> - {#if splitAmounts[user] > 0} - owes CHF {splitAmounts[user].toFixed(2)} - {:else if splitAmounts[user] < 0} - is owed CHF {Math.abs(splitAmounts[user]).toFixed(2)} - {:else} - owes CHF {splitAmounts[user].toFixed(2)} - {/if} - -
- {/each} -
- {/if} -
+ {#if error}
{error}
@@ -882,283 +614,9 @@ } } - .image-upload { - border: 2px dashed var(--nord4); - border-radius: 0.5rem; - padding: 2rem; - text-align: center; - cursor: pointer; - transition: all 0.2s; - background-color: var(--nord5); - } - - .image-upload:hover { - border-color: var(--blue); - background-color: var(--nord4); - } - - @media (prefers-color-scheme: dark) { - .image-upload { - background-color: var(--nord2); - border-color: var(--nord3); - } - - .image-upload:hover { - background-color: var(--nord3); - } - } - - .upload-label { - cursor: pointer; - display: block; - } - - .upload-content svg { - color: var(--nord3); - margin-bottom: 1rem; - } - - .upload-content p { - margin: 0 0 0.5rem 0; - font-weight: 500; - color: var(--nord0); - } - - .upload-content small { - color: var(--nord3); - } - - @media (prefers-color-scheme: dark) { - .upload-content svg { - color: var(--nord4); - } - - .upload-content p { - color: var(--font-default-dark); - } - - .upload-content small { - color: var(--nord4); - } - } - - .image-preview { - text-align: center; - } - - .image-preview img { - max-width: 100%; - max-height: 300px; - border-radius: 0.5rem; - margin-bottom: 1rem; - } - - .remove-image { - background-color: var(--red); - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 0.25rem; - cursor: pointer; - transition: all 0.2s; - } - - .remove-image:hover { - background-color: var(--nord11); - transform: translateY(-1px); - } - - .users-list { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-bottom: 1rem; - } - - .user-item { - display: flex; - align-items: center; - gap: 0.5rem; - background-color: var(--nord5); - padding: 0.5rem 0.75rem; - border-radius: 1rem; - border: 1px solid var(--nord4); - } - - @media (prefers-color-scheme: dark) { - .user-item { - background-color: var(--nord2); - border-color: var(--nord3); - } - } - - .user-item.with-profile { - gap: 0.75rem; - } - - .user-item .username { - font-weight: 500; - color: var(--nord0); - } - - .you-badge { - background-color: var(--blue); - color: white; - padding: 0.125rem 0.5rem; - border-radius: 1rem; - font-size: 0.75rem; - font-weight: 500; - } - - @media (prefers-color-scheme: dark) { - .user-item .username { - color: var(--font-default-dark); - } - } - - .predefined-users { - background-color: var(--nord5); - padding: 1rem; - border-radius: 0.5rem; - border: 1px solid var(--nord4); - } - - .predefined-note { - margin: 0 0 1rem 0; - color: var(--nord2); - font-size: 0.9rem; - font-style: italic; - } - - @media (prefers-color-scheme: dark) { - .predefined-users { - background-color: var(--nord2); - border-color: var(--nord3); - } - - .predefined-note { - color: var(--nord4); - } - } - - .remove-user { - background-color: var(--red); - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - font-size: 0.75rem; - cursor: pointer; - transition: all 0.2s; - } - - .remove-user:hover { - background-color: var(--nord11); - transform: translateY(-1px); - } - - .add-user { - display: flex; - gap: 0.5rem; - } - - .add-user input { - flex: 1; - } - - .add-user button { - background-color: var(--blue); - color: white; - border: none; - padding: 0.75rem 1rem; - border-radius: 0.5rem; - cursor: pointer; - transition: all 0.2s; - } - - .add-user button:hover { - background-color: var(--nord10); - transform: translateY(-1px); - } - .proportional-splits { - border: 1px solid var(--nord4); - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1rem; - background-color: var(--nord5); - } - @media (prefers-color-scheme: dark) { - .proportional-splits { - border-color: var(--nord3); - background-color: var(--nord2); - } - } - - .proportional-splits h3 { - margin-top: 0; - margin-bottom: 1rem; - } - - .split-input { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 0.5rem; - } - - .split-input label { - min-width: 100px; - margin-bottom: 0; - } - - .split-input input { - max-width: 120px; - } - - .split-preview { - background-color: var(--nord5); - padding: 1rem; - border-radius: 0.5rem; - border: 1px solid var(--nord4); - } - - @media (prefers-color-scheme: dark) { - .split-preview { - background-color: var(--nord2); - border-color: var(--nord3); - } - } - - .split-preview h3 { - margin-top: 0; - margin-bottom: 1rem; - } - - .split-item { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; - } - - .split-user { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .amount.positive { - color: var(--green); - font-weight: 500; - } - - .amount.negative { - color: var(--red); - font-weight: 500; - } .error { background-color: var(--nord6); @@ -1232,59 +690,6 @@ } } - .personal-splits { - margin-top: 1rem; - } - - .personal-splits .description { - color: var(--nord2); - font-size: 0.9rem; - margin-bottom: 1rem; - font-style: italic; - } - - @media (prefers-color-scheme: dark) { - .personal-splits .description { - color: var(--nord4); - } - } - - .remainder-info { - margin-top: 1rem; - padding: 1rem; - background-color: var(--nord5); - border-radius: 0.5rem; - border: 1px solid var(--nord4); - } - - .remainder-info.error { - background-color: var(--nord6); - border-color: var(--red); - } - - @media (prefers-color-scheme: dark) { - .remainder-info { - background-color: var(--nord2); - border-color: var(--nord3); - } - - .remainder-info.error { - background-color: var(--accent-dark); - } - } - - .remainder-info span { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - } - - .error-message { - color: var(--red); - font-weight: 600; - margin-top: 0.5rem; - font-size: 0.9rem; - } /* Progressive enhancement styles */ .no-js-only { diff --git a/src/routes/cospend/payments/edit/[id]/+page.svelte b/src/routes/cospend/payments/edit/[id]/+page.svelte index 1e2c020..92a35b1 100644 --- a/src/routes/cospend/payments/edit/[id]/+page.svelte +++ b/src/routes/cospend/payments/edit/[id]/+page.svelte @@ -2,6 +2,8 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { getCategoryOptions } from '$lib/utils/categories'; + import FormSection from '$lib/components/FormSection.svelte'; + import ImageUpload from '$lib/components/ImageUpload.svelte'; export let data; @@ -11,6 +13,7 @@ let uploading = false; let error = null; let imageFile = null; + let imagePreview = ''; $: categoryOptions = getCategoryOptions(); @@ -33,6 +36,24 @@ } } + function handleImageSelected(event) { + imageFile = event.detail; + handleImageUpload(); + } + + function handleImageError(event) { + error = event.detail; + } + + function handleImageRemoved() { + imageFile = null; + imagePreview = ''; + } + + function handleCurrentImageRemoved() { + payment.image = null; + } + async function handleImageUpload() { if (!imageFile) return; @@ -53,6 +74,7 @@ const result = await response.json(); payment.image = result.imageUrl; imageFile = null; + imagePreview = ''; } catch (err) { error = err.message; } finally { @@ -60,18 +82,6 @@ } } - function handleImageRemove() { - payment.image = null; - } - - function handleFileChange(event) { - const file = event.target.files[0]; - if (file) { - imageFile = file; - handleImageUpload(); - } - } - async function handleSubmit() { if (!payment) return; @@ -147,9 +157,7 @@
Error: {error}
{:else if payment}
-
-

Payment Details

- +
-
+ -
-

Receipt Image

- - {#if payment.image} -
- Receipt -
- -
-
- {/if} - -
- - - {#if uploading} -
Uploading image...
- {/if} -
-
+ {#if payment.splits && payment.splits.length > 0} -
-

Current Splits

+
{#each payment.splits as split}
@@ -266,7 +252,7 @@ {/each}

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

-
+
{/if}
@@ -551,101 +537,6 @@ cursor: not-allowed; } - .current-image { - margin-bottom: 1rem; - text-align: center; - } - - .receipt-preview { - max-width: 200px; - max-height: 200px; - object-fit: cover; - border-radius: 0.5rem; - border: 1px solid var(--nord4); - margin-bottom: 0.75rem; - display: block; - margin-left: auto; - margin-right: auto; - } - - @media (prefers-color-scheme: dark) { - .receipt-preview { - border-color: var(--nord2); - } - } - - .image-actions { - display: flex; - justify-content: center; - } - - .btn-remove { - background-color: var(--red); - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 0.25rem; - cursor: pointer; - font-size: 0.9rem; - transition: all 0.2s; - } - - .btn-remove:hover { - background-color: var(--nord11); - transform: translateY(-1px); - } - - .upload-label { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - color: var(--nord2); - cursor: pointer; - } - - @media (prefers-color-scheme: dark) { - .upload-label { - color: var(--nord5); - } - } - - .file-input { - width: 100%; - padding: 0.75rem; - border: 2px dashed var(--nord4); - border-radius: 0.5rem; - background-color: var(--nord5); - cursor: pointer; - transition: all 0.2s; - } - - .file-input:hover { - border-color: var(--blue); - background-color: var(--nord4); - } - - @media (prefers-color-scheme: dark) { - .file-input { - background-color: var(--nord2); - border-color: var(--nord3); - } - - .file-input:hover { - background-color: var(--nord3); - } - } - - .file-input:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - .upload-status { - margin-top: 0.5rem; - color: var(--blue); - font-size: 0.9rem; - text-align: center; - } @media (max-width: 600px) { .edit-payment { diff --git a/src/routes/cospend/recurring/edit/[id]/+page.svelte b/src/routes/cospend/recurring/edit/[id]/+page.svelte index 08716b1..cd9b33d 100644 --- a/src/routes/cospend/recurring/edit/[id]/+page.svelte +++ b/src/routes/cospend/recurring/edit/[id]/+page.svelte @@ -5,6 +5,8 @@ import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users'; import { validateCronExpression, getFrequencyDescription, calculateNextExecutionDate } from '$lib/utils/recurring'; import ProfilePicture from '$lib/components/ProfilePicture.svelte'; + import SplitMethodSelector from '$lib/components/SplitMethodSelector.svelte'; + import UsersList from '$lib/components/UsersList.svelte'; export let data; @@ -30,7 +32,6 @@ let loading = false; let loadingPayment = true; let error = null; - let personalTotalError = false; let predefinedMode = isPredefinedUsersMode(); let cronError = false; let nextExecutionPreview = ''; @@ -86,28 +87,6 @@ } } - function addUser() { - if (predefinedMode) return; - - if (newUser.trim() && !users.includes(newUser.trim())) { - users = [...users, newUser.trim()]; - addSplitForUser(newUser.trim()); - newUser = ''; - } - } - - function removeUser(userToRemove) { - if (predefinedMode) return; - - if (users.length > 1) { - users = users.filter(u => u !== userToRemove); - delete splitAmounts[userToRemove]; - delete personalAmounts[userToRemove]; - splitAmounts = { ...splitAmounts }; - personalAmounts = { ...personalAmounts }; - } - } - function addSplitForUser(username) { if (!splitAmounts[username]) { splitAmounts[username] = 0; @@ -115,74 +94,6 @@ } } - function calculateEqualSplits() { - if (!formData.amount || users.length === 0) return; - - const amountNum = parseFloat(formData.amount); - const splitAmount = amountNum / users.length; - - users.forEach(user => { - if (user === formData.paidBy) { - splitAmounts[user] = splitAmount - amountNum; - } else { - splitAmounts[user] = splitAmount; - } - }); - splitAmounts = { ...splitAmounts }; - } - - function calculateFullPayment() { - if (!formData.amount) return; - - const amountNum = parseFloat(formData.amount); - const otherUsers = users.filter(user => user !== formData.paidBy); - const amountPerOtherUser = otherUsers.length > 0 ? amountNum / otherUsers.length : 0; - - users.forEach(user => { - if (user === formData.paidBy) { - splitAmounts[user] = -amountNum; // They paid it all, so they're owed the full amount - } else { - splitAmounts[user] = amountPerOtherUser; // Others owe their share of the full amount - } - }); - splitAmounts = { ...splitAmounts }; - } - - function calculatePersonalEqualSplit() { - if (!formData.amount || users.length === 0) return; - - const totalAmount = parseFloat(formData.amount); - - const totalPersonal = users.reduce((sum, user) => { - return sum + (parseFloat(personalAmounts[user]) || 0); - }, 0); - - const remainder = Math.max(0, totalAmount - totalPersonal); - const equalShare = remainder / users.length; - - users.forEach(user => { - const personalAmount = parseFloat(personalAmounts[user]) || 0; - const totalOwed = personalAmount + equalShare; - - if (user === formData.paidBy) { - splitAmounts[user] = totalOwed - totalAmount; - } else { - splitAmounts[user] = totalOwed; - } - }); - splitAmounts = { ...splitAmounts }; - } - - function handleSplitMethodChange() { - if (formData.splitMethod === 'equal') { - calculateEqualSplits(); - } else if (formData.splitMethod === 'full') { - calculateFullPayment(); - } else if (formData.splitMethod === 'personal_equal') { - calculatePersonalEqualSplit(); - } - } - function validateCron() { if (formData.frequency !== 'custom') { cronError = false; @@ -224,15 +135,6 @@ return; } - if (formData.splitMethod === 'personal_equal') { - const totalPersonal = Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0); - const totalAmount = parseFloat(formData.amount); - - if (totalPersonal > totalAmount) { - error = 'Personal amounts cannot exceed the total payment amount'; - return; - } - } if (users.length === 0) { error = 'Please add at least one user to split with'; @@ -282,19 +184,6 @@ } } - $: if (formData.amount && formData.splitMethod && formData.paidBy && !loadingPayment) { - handleSplitMethodChange(); - } - - $: if (formData.splitMethod === 'personal_equal' && personalAmounts && formData.amount && !loadingPayment) { - const totalPersonal = Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0); - const totalAmount = parseFloat(formData.amount); - personalTotalError = totalPersonal > totalAmount; - - if (!personalTotalError) { - calculatePersonalEqualSplit(); - } - } $: if (formData.cronExpression) { validateCron(); @@ -460,129 +349,24 @@ {/if}
-
-

Split Between Users

- -
- {#each users as user} -
- - {user} - {#if user === data.session?.user?.nickname} - You - {/if} - {#if !predefinedMode && user !== data.session?.user?.nickname} - - {/if} -
- {/each} -
+ - {#if !predefinedMode} -
- e.key === 'Enter' && (e.preventDefault(), addUser())} - /> - -
- {/if} -
- -
-

Split Method

- -
- - - - -
- - {#if formData.splitMethod === 'proportional'} -
-

Custom Split Amounts

- {#each users as user} -
- - -
- {/each} -
- {/if} - - {#if formData.splitMethod === 'personal_equal'} -
-

Personal Amounts

-

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

- {#each users as user} -
- - -
- {/each} - {#if formData.amount} -
- Total Personal: CHF {Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0).toFixed(2)} - Remainder to Split: CHF {Math.max(0, parseFloat(formData.amount) - Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0)).toFixed(2)} - {#if personalTotalError} -
⚠️ Personal amounts exceed total payment amount!
- {/if} -
- {/if} -
- {/if} - - {#if Object.keys(splitAmounts).length > 0} -
-

Split Preview

- {#each users as user} -
-
- - {user} -
- 0}> - {#if splitAmounts[user] > 0} - owes CHF {splitAmounts[user].toFixed(2)} - {:else if splitAmounts[user] < 0} - is owed CHF {Math.abs(splitAmounts[user]).toFixed(2)} - {:else} - owes CHF {splitAmounts[user].toFixed(2)} - {/if} - -
- {/each} -
- {/if} -
+ {#if error}
{error}
@@ -616,11 +400,11 @@ .header h1 { margin: 0; - color: #333; + color: var(--nord0); } .back-link { - color: #1976d2; + color: var(--blue); text-decoration: none; } @@ -637,16 +421,17 @@ } .form-section { - background: white; + background: var(--nord6); padding: 1.5rem; border-radius: 0.75rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border: 1px solid var(--nord4); } .form-section h2 { margin-top: 0; margin-bottom: 1rem; - color: #333; + color: var(--nord0); font-size: 1.25rem; } @@ -664,39 +449,42 @@ display: block; margin-bottom: 0.5rem; font-weight: 500; - color: #555; + color: var(--nord3); } input, textarea, select { width: 100%; padding: 0.75rem; - border: 1px solid #ddd; + border: 1px solid var(--nord4); border-radius: 0.5rem; font-size: 1rem; box-sizing: border-box; + background: var(--nord5); + color: var(--nord0); } input:focus, textarea:focus, select:focus { outline: none; - border-color: #1976d2; - box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2); + border-color: var(--blue); + box-shadow: 0 0 0 2px rgba(136, 192, 208, 0.2); } input.error { - border-color: #d32f2f; + border-color: var(--red); } .help-text { margin-top: 0.5rem; - color: #666; + color: var(--nord3); font-size: 0.9rem; } .help-text code { - background-color: #f5f5f5; + background-color: var(--nord5); padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-family: monospace; + color: var(--nord0); } .help-text ul { @@ -709,14 +497,14 @@ } .field-error { - color: #d32f2f; + color: var(--red); font-size: 0.875rem; margin-top: 0.25rem; } .execution-preview { - background-color: #e3f2fd; - border: 1px solid #2196f3; + background-color: var(--nord8); + border: 1px solid var(--blue); border-radius: 0.5rem; padding: 1rem; margin-top: 1rem; @@ -724,200 +512,34 @@ .execution-preview h3 { margin: 0 0 0.5rem 0; - color: #1976d2; + color: var(--blue); font-size: 1rem; } .next-execution { font-size: 1.1rem; font-weight: 600; - color: #1976d2; + color: var(--blue); margin: 0.5rem 0; } .frequency-description { - color: #666; + color: var(--nord3); font-size: 0.9rem; margin: 0; font-style: italic; } - .users-list { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-bottom: 1rem; - } - .user-item { - display: flex; - align-items: center; - gap: 0.5rem; - background-color: #f5f5f5; - padding: 0.5rem 0.75rem; - border-radius: 1rem; - } - .user-item.with-profile { - gap: 0.75rem; - } - - .user-item .username { - font-weight: 500; - } - - .you-badge { - background-color: #1976d2; - color: white; - padding: 0.125rem 0.5rem; - border-radius: 1rem; - font-size: 0.75rem; - font-weight: 500; - } - - .remove-user { - background-color: #d32f2f; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - font-size: 0.75rem; - cursor: pointer; - } - - .add-user { - display: flex; - gap: 0.5rem; - } - - .add-user input { - flex: 1; - } - - .add-user button { - background-color: #1976d2; - color: white; - border: none; - padding: 0.75rem 1rem; - border-radius: 0.5rem; - cursor: pointer; - } - - .split-method { - display: flex; - flex-direction: column; - gap: 0.75rem; - margin-bottom: 1rem; - } - - .split-method label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - } - - .proportional-splits, .personal-splits { - border: 1px solid #ddd; - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1rem; - } - - .proportional-splits h3, .personal-splits h3 { - margin-top: 0; - margin-bottom: 1rem; - } - - .personal-splits .description { - color: #666; - font-size: 0.9rem; - margin-bottom: 1rem; - font-style: italic; - } - - .split-input { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 0.5rem; - } - - .split-input label { - min-width: 100px; - margin-bottom: 0; - } - - .split-input input { - max-width: 120px; - } - - .remainder-info { - margin-top: 1rem; - padding: 1rem; - background-color: #f8f9fa; - border-radius: 0.5rem; - border: 1px solid #e9ecef; - } - - .remainder-info.error { - background-color: #fff5f5; - border-color: #fed7d7; - } - - .remainder-info span { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - } - - .error-message { - color: #d32f2f; - font-weight: 600; - margin-top: 0.5rem; - font-size: 0.9rem; - } - - .split-preview { - background-color: #f8f9fa; - padding: 1rem; - border-radius: 0.5rem; - } - - .split-preview h3 { - margin-top: 0; - margin-bottom: 1rem; - } - - .split-item { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; - } - - .split-user { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .amount.positive { - color: #2e7d32; - font-weight: 500; - } - - .amount.negative { - color: #d32f2f; - font-weight: 500; - } .error { - background-color: #ffebee; - color: #d32f2f; + background-color: var(--nord6); + color: var(--red); padding: 1rem; border-radius: 0.5rem; margin-bottom: 1rem; + border: 1px solid var(--red); } .form-actions { @@ -935,13 +557,13 @@ } .btn-primary { - background-color: #1976d2; + background-color: var(--blue); color: white; border: none; } .btn-primary:hover:not(:disabled) { - background-color: #1565c0; + background-color: var(--lightblue); } .btn-primary:disabled { @@ -950,13 +572,71 @@ } .btn-secondary { - background-color: #f5f5f5; - color: #333; - border: 1px solid #ddd; + background-color: var(--nord5); + color: var(--nord0); + border: 1px solid var(--nord4); } .btn-secondary:hover { - background-color: #e8e8e8; + background-color: var(--nord4); + } + + @media (prefers-color-scheme: dark) { + .header h1 { + color: var(--font-default-dark); + } + + .form-section { + background: var(--accent-dark); + border-color: var(--nord2); + } + + .form-section h2 { + color: var(--font-default-dark); + } + + label { + color: var(--nord4); + } + + input, textarea, select { + background: var(--nord1); + color: var(--font-default-dark); + border-color: var(--nord2); + } + + input:focus, textarea:focus, select:focus { + box-shadow: 0 0 0 2px rgba(136, 192, 208, 0.2); + } + + .help-text { + color: var(--nord4); + } + + .help-text code { + background-color: var(--nord1); + color: var(--font-default-dark); + } + + .execution-preview { + background-color: var(--nord2); + border-color: var(--blue); + } + + + .error { + background-color: var(--accent-dark); + } + + .btn-secondary { + background-color: var(--nord1); + color: var(--font-default-dark); + border-color: var(--nord2); + } + + .btn-secondary:hover { + background-color: var(--nord2); + } } @media (max-width: 600px) { diff --git a/src/routes/cospend/settle/+page.server.ts b/src/routes/cospend/settle/+page.server.ts index 61409f0..f80003f 100644 --- a/src/routes/cospend/settle/+page.server.ts +++ b/src/routes/cospend/settle/+page.server.ts @@ -1,13 +1,133 @@ -import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; +import { fail, redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; -export const load: PageServerLoad = async ({ locals }) => { - const auth = await locals.auth(); - if (!auth || !auth.user) { +export const load: PageServerLoad = async ({ fetch, locals, request }) => { + const session = await locals.auth(); + + if (!session) { throw redirect(302, '/login'); } - return { - session: auth - }; + try { + // Fetch debt data server-side with authentication cookies + const response = await fetch('/api/cospend/debts', { + headers: { + 'Cookie': request.headers.get('Cookie') || '' + } + }); + if (!response.ok) { + throw new Error('Failed to fetch debt data'); + } + + const debtData = await response.json(); + + return { + debtData, + session, + currentUser: session.user?.nickname || '' + }; + } catch (error) { + console.error('Error loading debt data:', error); + return { + debtData: { + whoOwesMe: [], + whoIOwe: [], + totalOwedToMe: 0, + totalIOwe: 0 + }, + error: error.message, + session, + currentUser: session.user?.nickname || '' + }; + } +} + +export const actions: Actions = { + settle: async ({ request, fetch, locals }) => { + const data = await request.formData(); + + const settlementType = data.get('settlementType'); + const fromUser = data.get('fromUser'); + const toUser = data.get('toUser'); + const amount = parseFloat(data.get('amount')); + + // Validation + if (!settlementType || !fromUser || !toUser || !amount) { + return fail(400, { + error: 'All fields are required', + values: { + settlementType, + fromUser, + toUser, + amount: data.get('amount') + } + }); + } + + if (isNaN(amount) || amount <= 0) { + return fail(400, { + error: 'Please enter a valid positive amount', + values: { + settlementType, + fromUser, + toUser, + amount: data.get('amount') + } + }); + } + + try { + // Create a settlement payment + const payload = { + title: 'Settlement Payment', + description: `Settlement: ${fromUser} pays ${toUser}`, + amount: amount, + paidBy: fromUser, + date: new Date().toISOString().split('T')[0], + category: 'settlement', + splitMethod: 'full', + splits: [ + { + username: fromUser, + amount: -amount // Payer gets negative (receives money back) + }, + { + username: toUser, + amount: amount // Receiver owes money + } + ] + }; + + const response = await fetch('/api/cospend/payments', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cookie': request.headers.get('Cookie') || '' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to record settlement'); + } + + // Redirect back to dashboard on success + throw redirect(303, '/cospend'); + } catch (error) { + if (error.status === 303) { + throw error; // Re-throw redirect + } + + return fail(500, { + error: error.message, + values: { + settlementType, + fromUser, + toUser, + amount: data.get('amount') + } + }); + } + } }; \ No newline at end of file diff --git a/src/routes/cospend/settle/+page.svelte b/src/routes/cospend/settle/+page.svelte index d32b777..865681d 100644 --- a/src/routes/cospend/settle/+page.svelte +++ b/src/routes/cospend/settle/+page.svelte @@ -1,71 +1,60 @@