feat: add EN/DE internationalization to cospend section
All checks were successful
CI / update (push) Successful in 3m30s
All checks were successful
CI / update (push) Successful in 3m30s
Move cospend routes to parameterized [cospendRoot=cospendRoot] supporting both /cospend (DE) and /expenses (EN). Add cospendI18n.ts with 100+ translation keys covering all pages, components, categories, frequency descriptions, and error messages. Translate BarChart legend, ImageUpload, UsersList, SplitMethodSelector, DebtBreakdown, EnhancedBalance, and PaymentModal. Update LanguageSelector and hooks.server.ts for /expenses.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.11.3",
|
"version": "1.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ async function authorization({ event, resolve }: Parameters<Handle>[0]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Protect cospend routes and API endpoints
|
// Protect cospend routes and API endpoints
|
||||||
if (event.url.pathname.startsWith('/cospend') || event.url.pathname.startsWith('/api/cospend')) {
|
if (event.url.pathname.startsWith('/cospend') || event.url.pathname.startsWith('/expenses') || event.url.pathname.startsWith('/api/cospend')) {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
// Allow share-token access to shopping list routes
|
// Allow share-token access to shopping list routes
|
||||||
const isShoppingRoute = event.url.pathname.startsWith('/cospend/list') || event.url.pathname.startsWith('/api/cospend/list');
|
const isShoppingRoute = event.url.pathname.startsWith('/cospend/list') || event.url.pathname.startsWith('/expenses/list') || event.url.pathname.startsWith('/api/cospend/list');
|
||||||
const shareToken = event.url.searchParams.get('token');
|
const shareToken = event.url.searchParams.get('token');
|
||||||
if (isShoppingRoute && shareToken) {
|
if (isShoppingRoute && shareToken) {
|
||||||
const { validateShareToken } = await import('$lib/server/shoppingAuth');
|
const { validateShareToken } = await import('$lib/server/shoppingAuth');
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
imagePreview = $bindable(''),
|
imagePreview = $bindable(''),
|
||||||
imageFile = $bindable(null),
|
imageFile = $bindable(null),
|
||||||
uploading = $bindable(false),
|
uploading = $bindable(false),
|
||||||
currentImage = $bindable(null),
|
currentImage = $bindable(null),
|
||||||
title = 'Receipt Image',
|
title = undefined as string | undefined,
|
||||||
|
lang = 'de' as 'en' | 'de',
|
||||||
onerror,
|
onerror,
|
||||||
onimageSelected,
|
onimageSelected,
|
||||||
onimageRemoved,
|
onimageRemoved,
|
||||||
@@ -15,23 +18,26 @@
|
|||||||
uploading?: boolean,
|
uploading?: boolean,
|
||||||
currentImage?: string | null,
|
currentImage?: string | null,
|
||||||
title?: string,
|
title?: string,
|
||||||
|
lang?: 'en' | 'de',
|
||||||
onerror?: (message: string) => void,
|
onerror?: (message: string) => void,
|
||||||
onimageSelected?: (file: File) => void,
|
onimageSelected?: (file: File) => void,
|
||||||
onimageRemoved?: () => void,
|
onimageRemoved?: () => void,
|
||||||
oncurrentImageRemoved?: () => void
|
oncurrentImageRemoved?: () => void
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const displayTitle = $derived(title ?? t('receipt_image', lang));
|
||||||
|
|
||||||
function handleImageChange(event: Event) {
|
function handleImageChange(event: Event) {
|
||||||
const file = (event.target as HTMLInputElement).files?.[0];
|
const file = (event.target as HTMLInputElement).files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
onerror?.('File size must be less than 5MB');
|
onerror?.(t('file_too_large', lang));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!allowedTypes.includes(file.type)) {
|
||||||
onerror?.('Please select a valid image file (JPEG, PNG, WebP)');
|
onerror?.(t('invalid_image', lang));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,14 +66,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>{title}</h2>
|
<h2>{displayTitle}</h2>
|
||||||
|
|
||||||
{#if currentImage}
|
{#if currentImage}
|
||||||
<div class="current-image">
|
<div class="current-image">
|
||||||
<img src={currentImage} alt="Receipt" class="receipt-preview" />
|
<img src={currentImage} alt={t('receipt', lang)} class="receipt-preview" />
|
||||||
<div class="image-actions">
|
<div class="image-actions">
|
||||||
<button type="button" class="btn-remove" onclick={removeCurrentImage}>
|
<button type="button" class="btn-remove" onclick={removeCurrentImage}>
|
||||||
Remove Image
|
{t('remove_image', lang)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,9 +81,9 @@
|
|||||||
|
|
||||||
{#if imagePreview}
|
{#if imagePreview}
|
||||||
<div class="image-preview">
|
<div class="image-preview">
|
||||||
<img src={imagePreview} alt="Receipt preview" />
|
<img src={imagePreview} alt={t('receipt', lang)} />
|
||||||
<button type="button" class="remove-image" onclick={removeImage}>
|
<button type="button" class="remove-image" onclick={removeImage}>
|
||||||
Remove Image
|
{t('remove_image', lang)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -89,7 +95,7 @@
|
|||||||
<line x1="16" y1="5" x2="22" y2="5"/>
|
<line x1="16" y1="5" x2="22" y2="5"/>
|
||||||
<line x1="19" y1="2" x2="19" y2="8"/>
|
<line x1="19" y1="2" x2="19" y2="8"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p>{currentImage ? 'Replace Image' : 'Upload Receipt Image'}</p>
|
<p>{currentImage ? t('replace_image', lang) : t('upload_receipt', lang)}</p>
|
||||||
<small>JPEG, PNG, WebP (max 5MB)</small>
|
<small>JPEG, PNG, WebP (max 5MB)</small>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -105,7 +111,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if uploading}
|
{#if uploading}
|
||||||
<div class="upload-status">Uploading image...</div>
|
<div class="upload-status">{t('uploading_image', lang)}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { recipeTranslationStore } from '$lib/stores/recipeTranslation';
|
import { recipeTranslationStore } from '$lib/stores/recipeTranslation';
|
||||||
import { languageStore } from '$lib/stores/language';
|
import { languageStore } from '$lib/stores/language';
|
||||||
import { convertFitnessPath } from '$lib/js/fitnessI18n';
|
import { convertFitnessPath } from '$lib/js/fitnessI18n';
|
||||||
|
import { convertCospendPath } from '$lib/js/cospendI18n';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { lang = undefined }: { lang?: 'de' | 'en' | 'la' } = $props();
|
let { lang = undefined }: { lang?: 'de' | 'en' | 'la' } = $props();
|
||||||
@@ -41,6 +42,10 @@
|
|||||||
// Latin route — no language switching needed
|
// Latin route — no language switching needed
|
||||||
} else if (path.startsWith('/fitness')) {
|
} else if (path.startsWith('/fitness')) {
|
||||||
// Language is determined by sub-route slugs; don't override store
|
// Language is determined by sub-route slugs; don't override store
|
||||||
|
} else if (path.startsWith('/cospend')) {
|
||||||
|
languageStore.set('de');
|
||||||
|
} else if (path.startsWith('/expenses')) {
|
||||||
|
languageStore.set('en');
|
||||||
} else {
|
} else {
|
||||||
// On other pages, read from localStorage
|
// On other pages, read from localStorage
|
||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage !== 'undefined') {
|
||||||
@@ -83,6 +88,10 @@
|
|||||||
return convertFitnessPath(path, targetLang);
|
return convertFitnessPath(path, targetLang);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((path.startsWith('/cospend') || path.startsWith('/expenses')) && targetLang !== 'la') {
|
||||||
|
return convertCospendPath(path, targetLang);
|
||||||
|
}
|
||||||
|
|
||||||
// Use translated recipe slugs from page data when available (works during SSR)
|
// Use translated recipe slugs from page data when available (works during SSR)
|
||||||
const pageData = $page.data;
|
const pageData = $page.data;
|
||||||
if (targetLang === 'en' && path.startsWith('/rezepte')) {
|
if (targetLang === 'en' && path.startsWith('/rezepte')) {
|
||||||
@@ -125,7 +134,8 @@
|
|||||||
// dispatch event and stay on the page
|
// dispatch event and stay on the page
|
||||||
if (!path.startsWith('/rezepte') && !path.startsWith('/recipes')
|
if (!path.startsWith('/rezepte') && !path.startsWith('/recipes')
|
||||||
&& !path.startsWith('/glaube') && !path.startsWith('/faith') && !path.startsWith('/fides')
|
&& !path.startsWith('/glaube') && !path.startsWith('/faith') && !path.startsWith('/fides')
|
||||||
&& !path.startsWith('/fitness')) {
|
&& !path.startsWith('/fitness')
|
||||||
|
&& !path.startsWith('/cospend') && !path.startsWith('/expenses')) {
|
||||||
window.dispatchEvent(new CustomEvent('languagechange', { detail: { lang } }));
|
window.dispatchEvent(new CustomEvent('languagechange', { detail: { lang } }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -137,6 +147,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle cospend/expenses pages
|
||||||
|
if ((path.startsWith('/cospend') || path.startsWith('/expenses')) && lang !== 'la') {
|
||||||
|
const newPath = convertCospendPath(path, lang);
|
||||||
|
await goto(newPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle fitness pages
|
// Handle fitness pages
|
||||||
if (path.startsWith('/fitness')) {
|
if (path.startsWith('/fitness')) {
|
||||||
const newPath = convertFitnessPath(path, lang);
|
const newPath = convertFitnessPath(path, lang);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { Chart, registerables } from 'chart.js';
|
import { Chart, registerables } from 'chart.js';
|
||||||
|
import { paymentCategoryName } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {{
|
* @type {{
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
* onFilterChange?: ((categories: string[] | null) => void) | null
|
* onFilterChange?: ((categories: string[] | null) => void) | null
|
||||||
* }}
|
* }}
|
||||||
*/
|
*/
|
||||||
let { data = { labels: [], datasets: [] }, title = '', height = '400px', onFilterChange = null } = $props();
|
let { data = { labels: [], datasets: [] }, title = '', height = '400px', onFilterChange = null, lang = /** @type {'en' | 'de'} */ ('de') } = $props();
|
||||||
|
|
||||||
/** @type {HTMLCanvasElement | undefined} */
|
/** @type {HTMLCanvasElement | undefined} */
|
||||||
let canvas = $state(undefined);
|
let canvas = $state(undefined);
|
||||||
@@ -73,7 +74,7 @@
|
|||||||
} else {
|
} else {
|
||||||
const visible = c.data.datasets
|
const visible = c.data.datasets
|
||||||
.filter((/** @type {any} */ _, /** @type {number} */ idx) => !c.getDatasetMeta(idx).hidden)
|
.filter((/** @type {any} */ _, /** @type {number} */ idx) => !c.getDatasetMeta(idx).hidden)
|
||||||
.map((/** @type {any} */ ds) => /** @type {string} */ (ds.label ?? '').toLowerCase());
|
.map((/** @type {any} */ ds) => /** @type {string} */ (ds._categoryKey ?? ds.label ?? '').toLowerCase());
|
||||||
onFilterChange(visible);
|
onFilterChange(visible);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,11 +105,12 @@
|
|||||||
|
|
||||||
// Process datasets with colors and capitalize labels
|
// Process datasets with colors and capitalize labels
|
||||||
const processedDatasets = plainDatasets.map((/** @type {{ label: string, data: number[] }} */ dataset, /** @type {number} */ index) => ({
|
const processedDatasets = plainDatasets.map((/** @type {{ label: string, data: number[] }} */ dataset, /** @type {number} */ index) => ({
|
||||||
label: dataset.label.charAt(0).toUpperCase() + dataset.label.slice(1),
|
label: paymentCategoryName(dataset.label, lang),
|
||||||
data: dataset.data,
|
data: dataset.data,
|
||||||
backgroundColor: getCategoryColor(dataset.label, index),
|
backgroundColor: getCategoryColor(dataset.label, index),
|
||||||
borderColor: getCategoryColor(dataset.label, index),
|
borderColor: getCategoryColor(dataset.label, index),
|
||||||
borderWidth: 1
|
borderWidth: 1,
|
||||||
|
_categoryKey: dataset.label
|
||||||
}));
|
}));
|
||||||
|
|
||||||
chart = new Chart(ctx, {
|
chart = new Chart(ctx, {
|
||||||
@@ -334,6 +336,16 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recreate chart when lang changes
|
||||||
|
let prevLang = lang;
|
||||||
|
$effect(() => {
|
||||||
|
const currentLang = lang;
|
||||||
|
if (currentLang !== prevLang) {
|
||||||
|
prevLang = currentLang;
|
||||||
|
untrack(() => { if (canvas) createChart(); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
createChart();
|
createChart();
|
||||||
// Enable animations for subsequent updates (legend toggles, etc.)
|
// Enable animations for subsequent updates (legend toggles, etc.)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import ProfilePicture from './ProfilePicture.svelte';
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
import { formatCurrency } from '$lib/utils/formatters';
|
import { formatCurrency } from '$lib/utils/formatters';
|
||||||
|
import { detectCospendLang, locale, t } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ username: string, netAmount: number, transactions: Array<any> }} DebtEntry
|
* @typedef {{ username: string, netAmount: number, transactions: Array<any> }} DebtEntry
|
||||||
@@ -61,19 +66,19 @@
|
|||||||
|
|
||||||
{#if !shouldHide}
|
{#if !shouldHide}
|
||||||
<div class="debt-breakdown">
|
<div class="debt-breakdown">
|
||||||
<h2>Debt Overview</h2>
|
<h2>{t('debt_overview', lang)}</h2>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading debt breakdown...</div>
|
<div class="loading">{t('loading_debt_breakdown', lang)}</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">{t('error_prefix', lang)}: {error}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="debt-sections">
|
<div class="debt-sections">
|
||||||
{#if debtData.whoOwesMe.length > 0}
|
{#if debtData.whoOwesMe.length > 0}
|
||||||
<div class="debt-section owed-to-me">
|
<div class="debt-section owed-to-me">
|
||||||
<h3>Who owes you</h3>
|
<h3>{t('who_owes_you', lang)}</h3>
|
||||||
<div class="total-amount positive">
|
<div class="total-amount positive">
|
||||||
Total: {formatCurrency(debtData.totalOwedToMe, 'CHF', 'de-CH')}
|
{t('total', lang)}: {formatCurrency(debtData.totalOwedToMe, 'CHF', loc)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="debt-list">
|
<div class="debt-list">
|
||||||
@@ -83,11 +88,11 @@
|
|||||||
<ProfilePicture username={debt.username} size={40} />
|
<ProfilePicture username={debt.username} size={40} />
|
||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
<span class="username">{debt.username}</span>
|
<span class="username">{debt.username}</span>
|
||||||
<span class="amount positive">{formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
|
<span class="amount positive">{formatCurrency(debt.netAmount, 'CHF', loc)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="transaction-count">
|
<div class="transaction-count">
|
||||||
{debt.transactions.length} transaction{debt.transactions.length !== 1 ? 's' : ''}
|
{debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -97,9 +102,9 @@
|
|||||||
|
|
||||||
{#if debtData.whoIOwe.length > 0}
|
{#if debtData.whoIOwe.length > 0}
|
||||||
<div class="debt-section owe-to-others">
|
<div class="debt-section owe-to-others">
|
||||||
<h3>You owe</h3>
|
<h3>{t('you_owe_section', lang)}</h3>
|
||||||
<div class="total-amount negative">
|
<div class="total-amount negative">
|
||||||
Total: {formatCurrency(debtData.totalIOwe, 'CHF', 'de-CH')}
|
{t('total', lang)}: {formatCurrency(debtData.totalIOwe, 'CHF', loc)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="debt-list">
|
<div class="debt-list">
|
||||||
@@ -109,11 +114,11 @@
|
|||||||
<ProfilePicture username={debt.username} size={40} />
|
<ProfilePicture username={debt.username} size={40} />
|
||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
<span class="username">{debt.username}</span>
|
<span class="username">{debt.username}</span>
|
||||||
<span class="amount negative">{formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
|
<span class="amount negative">{formatCurrency(debt.netAmount, 'CHF', loc)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="transaction-count">
|
<div class="transaction-count">
|
||||||
{debt.transactions.length} transaction{debt.transactions.length !== 1 ? 's' : ''}
|
{debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import ProfilePicture from './ProfilePicture.svelte';
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
|
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
|
||||||
|
import { detectCospendLang, locale, t } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
let { initialBalance = null, initialDebtData = null } = $props<{ initialBalance?: any, initialDebtData?: any }>();
|
let { initialBalance = null, initialDebtData = null } = $props<{ initialBalance?: any, initialDebtData?: any }>();
|
||||||
|
|
||||||
@@ -98,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(amount: number) {
|
function formatCurrency(amount: number) {
|
||||||
return formatCurrencyUtil(Math.abs(amount), 'CHF', 'de-CH');
|
return formatCurrencyUtil(Math.abs(amount), 'CHF', loc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export refresh method for parent components to call
|
// Export refresh method for parent components to call
|
||||||
@@ -117,26 +122,26 @@
|
|||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading-content">
|
<div class="loading-content">
|
||||||
<h3>Your Balance</h3>
|
<h3>{t('your_balance', lang)}</h3>
|
||||||
<div class="loading">Loading...</div>
|
<div class="loading">{t('loading', lang)}</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<h3>Your Balance</h3>
|
<h3>{t('your_balance', lang)}</h3>
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">{t('error_prefix', lang)}: {error}</div>
|
||||||
{:else if shouldShowIntegratedView}
|
{:else if shouldShowIntegratedView}
|
||||||
<!-- Enhanced view with single user debt -->
|
<!-- Enhanced view with single user debt -->
|
||||||
<h3>Your Balance</h3>
|
<h3>{t('your_balance', lang)}</h3>
|
||||||
<div class="enhanced-balance">
|
<div class="enhanced-balance">
|
||||||
<div class="main-amount">
|
<div class="main-amount">
|
||||||
{#if balance.netBalance < 0}
|
{#if balance.netBalance < 0}
|
||||||
<span class="positive">+{formatCurrency(balance.netBalance)}</span>
|
<span class="positive">+{formatCurrency(balance.netBalance)}</span>
|
||||||
<small>You are owed</small>
|
<small>{t('you_are_owed', lang)}</small>
|
||||||
{:else if balance.netBalance > 0}
|
{:else if balance.netBalance > 0}
|
||||||
<span class="negative">-{formatCurrency(balance.netBalance)}</span>
|
<span class="negative">-{formatCurrency(balance.netBalance)}</span>
|
||||||
<small>You owe</small>
|
<small>{t('you_owe_balance', lang)}</small>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="even">CHF 0.00</span>
|
<span class="even">CHF 0.00</span>
|
||||||
<small>You're all even</small>
|
<small>{t('all_even', lang)}</small>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,9 +154,9 @@
|
|||||||
<span class="username">{singleDebtUser.user.username}</span>
|
<span class="username">{singleDebtUser.user.username}</span>
|
||||||
<span class="debt-description">
|
<span class="debt-description">
|
||||||
{#if singleDebtUser.type === 'owesMe'}
|
{#if singleDebtUser.type === 'owesMe'}
|
||||||
owes you {formatCurrency(singleDebtUser.amount)}
|
{t('owes_you_balance', lang)} {formatCurrency(singleDebtUser.amount)}
|
||||||
{:else}
|
{:else}
|
||||||
you owe {formatCurrency(singleDebtUser.amount)}
|
{t('you_owe_user', lang)} {formatCurrency(singleDebtUser.amount)}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,24 +166,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="transaction-count">
|
<div class="transaction-count">
|
||||||
{#if singleDebtUser && singleDebtUser.user && singleDebtUser.user.transactions}
|
{#if singleDebtUser && singleDebtUser.user && singleDebtUser.user.transactions}
|
||||||
{singleDebtUser.user.transactions.length} transaction{singleDebtUser.user.transactions.length !== 1 ? 's' : ''}
|
{singleDebtUser.user.transactions.length} {singleDebtUser.user.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Standard balance view -->
|
<!-- Standard balance view -->
|
||||||
<h3>Your Balance</h3>
|
<h3>{t('your_balance', lang)}</h3>
|
||||||
<div class="amount">
|
<div class="amount">
|
||||||
{#if balance.netBalance < 0}
|
{#if balance.netBalance < 0}
|
||||||
<span class="positive">+{formatCurrency(balance.netBalance)}</span>
|
<span class="positive">+{formatCurrency(balance.netBalance)}</span>
|
||||||
<small>You are owed</small>
|
<small>{t('you_are_owed', lang)}</small>
|
||||||
{:else if balance.netBalance > 0}
|
{:else if balance.netBalance > 0}
|
||||||
<span class="negative">-{formatCurrency(balance.netBalance)}</span>
|
<span class="negative">-{formatCurrency(balance.netBalance)}</span>
|
||||||
<small>You owe</small>
|
<small>{t('you_owe_balance', lang)}</small>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="even">CHF 0.00</span>
|
<span class="even">CHF 0.00</span>
|
||||||
<small>You're all even</small>
|
<small>{t('all_even', lang)}</small>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -4,14 +4,19 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import ProfilePicture from './ProfilePicture.svelte';
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
import EditButton from '$lib/components/EditButton.svelte';
|
import EditButton from '$lib/components/EditButton.svelte';
|
||||||
import { getCategoryEmoji, getCategoryName, PAYMENT_CATEGORIES } from '$lib/utils/categories';
|
import { getCategoryEmoji } from '$lib/utils/categories';
|
||||||
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
|
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
|
||||||
|
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
let { paymentId, onclose, onpaymentDeleted } = $props();
|
let { paymentId, onclose, onpaymentDeleted } = $props();
|
||||||
|
|
||||||
// Get session from page store
|
// Get session from page store
|
||||||
let session = $derived($page.data?.session);
|
let session = $derived($page.data?.session);
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const root = $derived(cospendRoot(lang));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
* _id?: string,
|
* _id?: string,
|
||||||
@@ -76,7 +81,7 @@
|
|||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
// Use shallow routing to go back to dashboard without full navigation
|
// Use shallow routing to go back to dashboard without full navigation
|
||||||
goto('/cospend/dash', { replaceState: true, noScroll: true, keepFocus: true });
|
goto(`/${root}/dash`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
onclose?.();
|
onclose?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,33 +94,23 @@
|
|||||||
|
|
||||||
/** @param {number} amount */
|
/** @param {number} amount */
|
||||||
function formatCurrency(amount) {
|
function formatCurrency(amount) {
|
||||||
return formatCurrencyUtil(Math.abs(amount), 'CHF', 'de-CH');
|
return formatCurrencyUtil(Math.abs(amount), 'CHF', loc);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string} dateString */
|
/** @param {string} dateString */
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
return new Date(dateString).toLocaleDateString('de-CH');
|
return new Date(dateString).toLocaleDateString(loc);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {PaymentData} payment */
|
/** @param {PaymentData} payment */
|
||||||
function getSplitDescription(payment) {
|
function getSplitDescription(payment) {
|
||||||
if (!payment.splits || payment.splits.length === 0) return 'No splits';
|
return splitDescription(payment, lang);
|
||||||
|
|
||||||
if (payment.splitMethod === 'equal') {
|
|
||||||
return `Split equally among ${payment.splits.length} people`;
|
|
||||||
} else if (payment.splitMethod === 'full') {
|
|
||||||
return `Paid in full by ${payment.paidBy}`;
|
|
||||||
} else if (payment.splitMethod === 'personal_equal') {
|
|
||||||
return `Personal amounts + equal split among ${payment.splits.length} people`;
|
|
||||||
} else {
|
|
||||||
return `Custom split among ${payment.splits.length} people`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
|
|
||||||
async function deletePayment() {
|
async function deletePayment() {
|
||||||
if (!confirm('Are you sure you want to delete this payment? This action cannot be undone.')) {
|
if (!confirm(t('delete_payment_confirm', lang))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +138,7 @@
|
|||||||
|
|
||||||
<div class="panel-content" bind:this={modal}>
|
<div class="panel-content" bind:this={modal}>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>Payment Details</h2>
|
<h2>{t('payment_details', lang)}</h2>
|
||||||
<button class="close-button" onclick={closeModal} aria-label="Close modal">
|
<button class="close-button" onclick={closeModal} aria-label="Close modal">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
@@ -154,9 +149,9 @@
|
|||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading payment...</div>
|
<div class="loading">{t('loading_payments', lang)}</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">{t('error_prefix', lang)}: {error}</div>
|
||||||
{:else if payment}
|
{:else if payment}
|
||||||
<div class="payment-details">
|
<div class="payment-details">
|
||||||
<div class="payment-header">
|
<div class="payment-header">
|
||||||
@@ -171,7 +166,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if payment.image}
|
{#if payment.image}
|
||||||
<div class="receipt-image">
|
<div class="receipt-image">
|
||||||
<img src={payment.image} alt="Receipt" />
|
<img src={payment.image} alt={t('receipt', lang)} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,30 +174,30 @@
|
|||||||
<div class="payment-info">
|
<div class="payment-info">
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Date:</span>
|
<span class="label">{t('date', lang)}</span>
|
||||||
<span class="value">{formatDate(payment.date)}</span>
|
<span class="value">{formatDate(payment.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Paid by:</span>
|
<span class="label">{t('paid_by_label', lang)}</span>
|
||||||
<span class="value">{payment.paidBy}</span>
|
<span class="value">{payment.paidBy}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Created by:</span>
|
<span class="label">{t('created_by', lang)}</span>
|
||||||
<span class="value">{payment.createdBy}</span>
|
<span class="value">{payment.createdBy}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Category:</span>
|
<span class="label">{t('category_label', lang)}</span>
|
||||||
<span class="value">{getCategoryName(payment.category || 'groceries')}</span>
|
<span class="value">{paymentCategoryName(payment.category || 'groceries', lang)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Split method:</span>
|
<span class="label">{t('split_method_label', lang)}</span>
|
||||||
<span class="value">{getSplitDescription(payment)}</span>
|
<span class="value">{getSplitDescription(payment)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if payment.description}
|
{#if payment.description}
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<h3>Description</h3>
|
<h3>{t('description', lang)}</h3>
|
||||||
<p>{payment.description}</p>
|
<p>{payment.description}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -210,7 +205,7 @@
|
|||||||
|
|
||||||
{#if payment.splits && payment.splits.length > 0}
|
{#if payment.splits && payment.splits.length > 0}
|
||||||
<div class="splits-section">
|
<div class="splits-section">
|
||||||
<h3>Split Details</h3>
|
<h3>{t('split_details', lang)}</h3>
|
||||||
<div class="splits-list">
|
<div class="splits-list">
|
||||||
{#each payment.splits as split}
|
{#each payment.splits as split}
|
||||||
<div class="split-item" class:current-user={split.username === session?.user?.nickname}>
|
<div class="split-item" class:current-user={split.username === session?.user?.nickname}>
|
||||||
@@ -219,17 +214,17 @@
|
|||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="username">{split.username}</span>
|
<span class="username">{split.username}</span>
|
||||||
{#if split.username === session?.user?.nickname}
|
{#if split.username === session?.user?.nickname}
|
||||||
<span class="you-badge">You</span>
|
<span class="you-badge">{t('you', lang)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
|
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
|
||||||
{#if split.amount > 0}
|
{#if split.amount > 0}
|
||||||
owes {formatCurrency(split.amount)}
|
{t('owes', lang)} {formatCurrency(split.amount)}
|
||||||
{:else if split.amount < 0}
|
{:else if split.amount < 0}
|
||||||
owed {formatCurrency(split.amount)}
|
{t('owed', lang)} {formatCurrency(split.amount)}
|
||||||
{:else}
|
{:else}
|
||||||
even
|
{t('even', lang)}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +234,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="panel-actions">
|
<div class="panel-actions">
|
||||||
<button class="btn-secondary" onclick={closeModal}>Close</button>
|
<button class="btn-secondary" onclick={closeModal}>{t('close', lang)}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -247,7 +242,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if payment}
|
{#if payment}
|
||||||
<EditButton href="/cospend/payments/edit/{paymentId}" />
|
<EditButton href="/{root}/payments/edit/{paymentId}" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import ProfilePicture from './ProfilePicture.svelte';
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { detectCospendLang, t } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
|
||||||
let {
|
let {
|
||||||
splitMethod = $bindable('equal'),
|
splitMethod = $bindable('equal'),
|
||||||
@@ -18,20 +22,20 @@
|
|||||||
// Reactive text for "Paid in Full" option
|
// Reactive text for "Paid in Full" option
|
||||||
let paidInFullText = $derived((() => {
|
let paidInFullText = $derived((() => {
|
||||||
if (!paidBy) {
|
if (!paidBy) {
|
||||||
return 'Paid in Full';
|
return t('paid_in_full', lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for 2-user predefined setup
|
// Special handling for 2-user predefined setup
|
||||||
if (predefinedMode && users.length === 2) {
|
if (predefinedMode && users.length === 2) {
|
||||||
const otherUser = users.find((/** @type {string} */ user) => user !== paidBy);
|
const otherUser = users.find((/** @type {string} */ user) => user !== paidBy);
|
||||||
return otherUser ? `Paid in Full for ${otherUser}` : 'Paid in Full';
|
return otherUser ? `${t('paid_in_full_for', lang)} ${otherUser}` : t('paid_in_full', lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
// General case
|
// General case
|
||||||
if (paidBy === currentUser) {
|
if (paidBy === currentUser) {
|
||||||
return 'Paid in Full by You';
|
return t('paid_in_full_by_you', lang);
|
||||||
} else {
|
} else {
|
||||||
return `Paid in Full by ${paidBy}`;
|
return `${t('paid_in_full_by', lang)} ${paidBy}`;
|
||||||
}
|
}
|
||||||
})());
|
})());
|
||||||
|
|
||||||
@@ -128,21 +132,21 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Split Method</h2>
|
<h2>{t('split_method', lang)}</h2>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="splitMethod">How should this payment be split?</label>
|
<label for="splitMethod">{t('how_split', lang)}</label>
|
||||||
<select id="splitMethod" name="splitMethod" bind:value={splitMethod} required>
|
<select id="splitMethod" name="splitMethod" bind:value={splitMethod} required>
|
||||||
<option value="equal">{predefinedMode && users.length === 2 ? 'Split 50/50' : 'Equal Split'}</option>
|
<option value="equal">{predefinedMode && users.length === 2 ? t('split_5050', lang) : t('equal_split', lang)}</option>
|
||||||
<option value="personal_equal">Personal + Equal Split</option>
|
<option value="personal_equal">{t('personal_equal_split', lang)}</option>
|
||||||
<option value="full">{paidInFullText}</option>
|
<option value="full">{paidInFullText}</option>
|
||||||
<option value="proportional">Custom Proportions</option>
|
<option value="proportional">{t('custom_proportions', lang)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if splitMethod === 'proportional'}
|
{#if splitMethod === 'proportional'}
|
||||||
<div class="proportional-splits">
|
<div class="proportional-splits">
|
||||||
<h3>Custom Split Amounts</h3>
|
<h3>{t('custom_split_amounts', lang)}</h3>
|
||||||
{#each users as user}
|
{#each users as user}
|
||||||
<div class="split-input">
|
<div class="split-input">
|
||||||
<label for="split_{user}">{user}</label>
|
<label for="split_{user}">{user}</label>
|
||||||
@@ -161,8 +165,8 @@
|
|||||||
|
|
||||||
{#if splitMethod === 'personal_equal'}
|
{#if splitMethod === 'personal_equal'}
|
||||||
<div class="personal-splits">
|
<div class="personal-splits">
|
||||||
<h3>Personal Amounts</h3>
|
<h3>{t('personal_amounts', lang)}</h3>
|
||||||
<p class="description">Enter personal amounts for each user. The remainder will be split equally.</p>
|
<p class="description">{t('personal_amounts_desc', lang)}</p>
|
||||||
{#each users as user}
|
{#each users as user}
|
||||||
<div class="split-input">
|
<div class="split-input">
|
||||||
<label for="personal_{user}">{user}</label>
|
<label for="personal_{user}">{user}</label>
|
||||||
@@ -180,10 +184,10 @@
|
|||||||
{#if amount}
|
{#if amount}
|
||||||
{@const personalTotal = Object.values(personalAmounts).reduce((/** @type {number} */ sum, /** @type {number} */ val) => sum + (Number(val) || 0), 0)}
|
{@const personalTotal = Object.values(personalAmounts).reduce((/** @type {number} */ sum, /** @type {number} */ val) => sum + (Number(val) || 0), 0)}
|
||||||
<div class="remainder-info" class:error={personalTotalError}>
|
<div class="remainder-info" class:error={personalTotalError}>
|
||||||
<span>Total Personal: {currency} {personalTotal.toFixed(2)}</span>
|
<span>{t('total_personal', lang)}: {currency} {personalTotal.toFixed(2)}</span>
|
||||||
<span>Remainder to Split: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)}</span>
|
<span>{t('remainder_to_split', lang)}: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)}</span>
|
||||||
{#if personalTotalError}
|
{#if personalTotalError}
|
||||||
<div class="error-message">Warning: Personal amounts exceed total payment amount!</div>
|
<div class="error-message">{t('personal_exceeds_total', lang)}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -192,7 +196,7 @@
|
|||||||
|
|
||||||
{#if Object.keys(splitAmounts).length > 0}
|
{#if Object.keys(splitAmounts).length > 0}
|
||||||
<div class="split-preview">
|
<div class="split-preview">
|
||||||
<h3>Split Preview</h3>
|
<h3>{t('split_preview', lang)}</h3>
|
||||||
{#each users as user}
|
{#each users as user}
|
||||||
<div class="split-item">
|
<div class="split-item">
|
||||||
<div class="split-user">
|
<div class="split-user">
|
||||||
@@ -201,11 +205,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="amount" class:positive={splitAmounts[user] < 0} class:negative={splitAmounts[user] > 0}>
|
<span class="amount" class:positive={splitAmounts[user] < 0} class:negative={splitAmounts[user] > 0}>
|
||||||
{#if splitAmounts[user] > 0}
|
{#if splitAmounts[user] > 0}
|
||||||
owes {currency} {splitAmounts[user].toFixed(2)}
|
{t('owes', lang)} {currency} {splitAmounts[user].toFixed(2)}
|
||||||
{:else if splitAmounts[user] < 0}
|
{:else if splitAmounts[user] < 0}
|
||||||
is owed {currency} {Math.abs(splitAmounts[user]).toFixed(2)}
|
{t('is_owed', lang)} {currency} {Math.abs(splitAmounts[user]).toFixed(2)}
|
||||||
{:else}
|
{:else}
|
||||||
owes {currency} {splitAmounts[user].toFixed(2)}
|
{t('owes', lang)} {currency} {splitAmounts[user].toFixed(2)}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ProfilePicture from './ProfilePicture.svelte';
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
|
import { t } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
users = $bindable([]),
|
users = $bindable([]),
|
||||||
currentUser = '',
|
currentUser = '',
|
||||||
predefinedMode = false,
|
predefinedMode = false,
|
||||||
canRemoveUsers = true,
|
canRemoveUsers = true,
|
||||||
newUser = $bindable('')
|
newUser = $bindable(''),
|
||||||
|
lang = 'de' as 'en' | 'de'
|
||||||
} = $props<{
|
} = $props<{
|
||||||
users?: string[],
|
users?: string[],
|
||||||
currentUser?: string,
|
currentUser?: string,
|
||||||
predefinedMode?: boolean,
|
predefinedMode?: boolean,
|
||||||
canRemoveUsers?: boolean,
|
canRemoveUsers?: boolean,
|
||||||
newUser?: string
|
newUser?: string,
|
||||||
|
lang?: 'en' | 'de'
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function addUser() {
|
function addUser() {
|
||||||
@@ -35,18 +38,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Split Between Users</h2>
|
<h2>{t('split_between_users', lang)}</h2>
|
||||||
|
|
||||||
{#if predefinedMode}
|
{#if predefinedMode}
|
||||||
<div class="predefined-users">
|
<div class="predefined-users">
|
||||||
<p class="predefined-note">Splitting between predefined users:</p>
|
<p class="predefined-note">{t('predefined_note', lang)}</p>
|
||||||
<div class="users-list">
|
<div class="users-list">
|
||||||
{#each users as user}
|
{#each users as user}
|
||||||
<div class="user-item with-profile">
|
<div class="user-item with-profile">
|
||||||
<ProfilePicture username={user} size={32} />
|
<ProfilePicture username={user} size={32} />
|
||||||
<span class="username">{user}</span>
|
<span class="username">{user}</span>
|
||||||
{#if user === currentUser}
|
{#if user === currentUser}
|
||||||
<span class="you-badge">You</span>
|
<span class="you-badge">{t('you', lang)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -59,11 +62,11 @@
|
|||||||
<ProfilePicture username={user} size={32} />
|
<ProfilePicture username={user} size={32} />
|
||||||
<span class="username">{user}</span>
|
<span class="username">{user}</span>
|
||||||
{#if user === currentUser}
|
{#if user === currentUser}
|
||||||
<span class="you-badge">You</span>
|
<span class="you-badge">{t('you', lang)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if canRemoveUsers && user !== currentUser}
|
{#if canRemoveUsers && user !== currentUser}
|
||||||
<button type="button" class="remove-user" onclick={() => removeUser(user)}>
|
<button type="button" class="remove-user" onclick={() => removeUser(user)}>
|
||||||
Remove
|
{t('remove', lang)}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -74,10 +77,10 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newUser}
|
bind:value={newUser}
|
||||||
placeholder="Add user..."
|
placeholder={t('add_user_placeholder', lang)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addUser())}
|
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addUser())}
|
||||||
/>
|
/>
|
||||||
<button type="button" onclick={addUser}>Add User</button>
|
<button type="button" onclick={addUser}>{t('add_user', lang)}</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
451
src/lib/js/cospendI18n.ts
Normal file
451
src/lib/js/cospendI18n.ts
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
/** Cospend route i18n — slug mappings and UI translations */
|
||||||
|
|
||||||
|
/** Detect language from a cospend path by checking the root segment */
|
||||||
|
export function detectCospendLang(pathname: string): 'en' | 'de' {
|
||||||
|
const first = pathname.split('/').filter(Boolean)[0];
|
||||||
|
return first === 'expenses' ? 'en' : 'de';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a cospend path to the target language */
|
||||||
|
export function convertCospendPath(pathname: string, targetLang: 'en' | 'de'): string {
|
||||||
|
const targetRoot = targetLang === 'en' ? 'expenses' : 'cospend';
|
||||||
|
return pathname.replace(/^\/(cospend|expenses)/, `/${targetRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the root slug for a given language */
|
||||||
|
export function cospendRoot(lang: 'en' | 'de'): string {
|
||||||
|
return lang === 'en' ? 'expenses' : 'cospend';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get translated nav labels */
|
||||||
|
export function cospendLabels(lang: 'en' | 'de') {
|
||||||
|
return {
|
||||||
|
dash: lang === 'en' ? 'Dashboard' : 'Dashboard',
|
||||||
|
list: lang === 'en' ? 'List' : 'Liste',
|
||||||
|
payments: lang === 'en' ? 'All Payments' : 'Alle Zahlungen',
|
||||||
|
recurring: lang === 'en' ? 'Recurring' : 'Wiederkehrend'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Translations = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
|
const translations: Translations = {
|
||||||
|
// Page titles
|
||||||
|
cospend_title: { en: 'Expenses - Expense Sharing', de: 'Cospend - Ausgabenteilung' },
|
||||||
|
all_payments_title: { en: 'All Payments', de: 'Alle Zahlungen' },
|
||||||
|
settle_title: { en: 'Settle Debts', de: 'Schulden begleichen' },
|
||||||
|
recurring_title: { en: 'Recurring Payments', de: 'Wiederkehrende Zahlungen' },
|
||||||
|
shopping_list_title: { en: 'Shopping List', de: 'Einkaufsliste' },
|
||||||
|
payment_details: { en: 'Payment Details', de: 'Zahlungsdetails' },
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
cospend: { en: 'Expenses', de: 'Cospend' },
|
||||||
|
settle_debts: { en: 'Settle Debts', de: 'Schulden begleichen' },
|
||||||
|
monthly_expenses_chart: { en: 'Monthly Expenses by Category', de: 'Monatliche Ausgaben nach Kategorie' },
|
||||||
|
loading_monthly: { en: 'Loading monthly expenses chart...', de: 'Monatliche Ausgaben werden geladen...' },
|
||||||
|
loading_recent: { en: 'Loading recent activity...', de: 'Letzte Aktivitäten werden geladen...' },
|
||||||
|
recent_activity: { en: 'Recent Activity', de: 'Letzte Aktivität' },
|
||||||
|
clear_filter: { en: 'Clear filter', de: 'Filter löschen' },
|
||||||
|
no_recent_in: { en: 'No recent activity in', de: 'Keine Aktivität in' },
|
||||||
|
paid_by: { en: 'Paid by', de: 'Bezahlt von' },
|
||||||
|
payment: { en: 'Payment', de: 'Zahlung' },
|
||||||
|
|
||||||
|
// All Payments page
|
||||||
|
loading_payments: { en: 'Loading payments...', de: 'Zahlungen werden geladen...' },
|
||||||
|
no_payments_yet: { en: 'No payments yet', de: 'Noch keine Zahlungen' },
|
||||||
|
start_first_expense: { en: 'Start by adding your first shared expense', de: 'Füge deine erste geteilte Ausgabe hinzu' },
|
||||||
|
add_first_payment: { en: 'Add Your First Payment', de: 'Erste Zahlung hinzufügen' },
|
||||||
|
settlement: { en: 'Settlement', de: 'Ausgleich' },
|
||||||
|
split_details: { en: 'Split Details', de: 'Aufteilung' },
|
||||||
|
owes: { en: 'owes', de: 'schuldet' },
|
||||||
|
owed: { en: 'owed', de: 'bekommt' },
|
||||||
|
even: { en: 'even', de: 'ausgeglichen' },
|
||||||
|
previous: { en: '← Previous', de: '← Zurück' },
|
||||||
|
next: { en: 'Next →', de: 'Weiter →' },
|
||||||
|
load_more: { en: 'Load More', de: 'Mehr laden' },
|
||||||
|
loading_ellipsis: { en: 'Loading...', de: 'Laden...' },
|
||||||
|
delete_payment_confirm: { en: 'Are you sure you want to delete this payment?', de: 'Diese Zahlung wirklich löschen?' },
|
||||||
|
|
||||||
|
// Payment detail labels
|
||||||
|
date: { en: 'Date:', de: 'Datum:' },
|
||||||
|
paid_by_label: { en: 'Paid by:', de: 'Bezahlt von:' },
|
||||||
|
created_by: { en: 'Created by:', de: 'Erstellt von:' },
|
||||||
|
category_label: { en: 'Category:', de: 'Kategorie:' },
|
||||||
|
split_method_label: { en: 'Split method:', de: 'Aufteilungsart:' },
|
||||||
|
description: { en: 'Description', de: 'Beschreibung' },
|
||||||
|
exchange_rate: { en: 'Exchange rate', de: 'Wechselkurs' },
|
||||||
|
receipt: { en: 'Receipt', de: 'Beleg' },
|
||||||
|
receipt_image: { en: 'Receipt Image', de: 'Belegbild' },
|
||||||
|
remove_image: { en: 'Remove Image', de: 'Bild entfernen' },
|
||||||
|
replace_image: { en: 'Replace Image', de: 'Bild ersetzen' },
|
||||||
|
upload_receipt: { en: 'Upload Receipt Image', de: 'Beleg hochladen' },
|
||||||
|
uploading_image: { en: 'Uploading image...', de: 'Bild wird hochgeladen...' },
|
||||||
|
file_too_large: { en: 'File size must be less than 5MB', de: 'Dateigrösse muss unter 5MB sein' },
|
||||||
|
invalid_image: { en: 'Please select a valid image file (JPEG, PNG, WebP)', de: 'Bitte eine gültige Bilddatei wählen (JPEG, PNG, WebP)' },
|
||||||
|
you: { en: 'You', de: 'Du' },
|
||||||
|
close: { en: 'Close', de: 'Schliessen' },
|
||||||
|
|
||||||
|
// Split descriptions
|
||||||
|
no_splits: { en: 'No splits', de: 'Keine Aufteilung' },
|
||||||
|
split_equal: { en: 'Split equally among', de: 'Gleichmässig aufgeteilt auf' },
|
||||||
|
paid_full_by: { en: 'Paid in full by', de: 'Vollständig bezahlt von' },
|
||||||
|
personal_equal: { en: 'Personal amounts + equal split among', de: 'Persönliche Beträge + Gleichverteilung auf' },
|
||||||
|
custom_split: { en: 'Custom split among', de: 'Individuelle Aufteilung auf' },
|
||||||
|
people: { en: 'people', de: 'Personen' },
|
||||||
|
|
||||||
|
// Settle page
|
||||||
|
settle_subtitle: { en: 'Record payments to settle outstanding debts between users', de: 'Zahlungen erfassen, um offene Schulden auszugleichen' },
|
||||||
|
loading_debts: { en: 'Loading debt information...', de: 'Schuldeninformationen werden geladen...' },
|
||||||
|
all_settled: { en: 'All Settled!', de: 'Alles beglichen!' },
|
||||||
|
no_debts_msg: { en: 'No outstanding debts to settle. Everyone is even!', de: 'Keine offenen Schulden. Alle sind ausgeglichen!' },
|
||||||
|
back_to_dashboard: { en: 'Back to Dashboard', de: 'Zurück zum Dashboard' },
|
||||||
|
available_settlements: { en: 'Available Settlements', de: 'Mögliche Ausgleiche' },
|
||||||
|
money_owed_to_you: { en: "Money You're Owed", de: 'Geld, das du bekommst' },
|
||||||
|
owes_you: { en: 'owes you', de: 'schuldet dir' },
|
||||||
|
receive_payment: { en: 'Receive Payment', de: 'Zahlung empfangen' },
|
||||||
|
money_you_owe: { en: 'Money You Owe', de: 'Geld, das du schuldest' },
|
||||||
|
you_owe: { en: 'you owe', de: 'du schuldest' },
|
||||||
|
make_payment: { en: 'Make Payment', de: 'Zahlung leisten' },
|
||||||
|
settlement_details: { en: 'Settlement Details', de: 'Ausgleichsdetails' },
|
||||||
|
settlement_amount: { en: 'Settlement Amount', de: 'Ausgleichsbetrag' },
|
||||||
|
record_settlement: { en: 'Record Settlement', de: 'Ausgleich erfassen' },
|
||||||
|
recording_settlement: { en: 'Recording Settlement...', de: 'Ausgleich wird erfasst...' },
|
||||||
|
cancel: { en: 'Cancel', de: 'Abbrechen' },
|
||||||
|
settlement_type: { en: 'Settlement Type', de: 'Ausgleichsart' },
|
||||||
|
select_settlement: { en: 'Select settlement type', de: 'Ausgleichsart wählen' },
|
||||||
|
receive_from: { en: 'Receive', de: 'Empfangen' },
|
||||||
|
from: { en: 'from', de: 'von' },
|
||||||
|
pay_to: { en: 'Pay', de: 'Zahlen' },
|
||||||
|
to: { en: 'to', de: 'an' },
|
||||||
|
from_user: { en: 'From User', de: 'Von Benutzer' },
|
||||||
|
select_payer: { en: 'Select payer', de: 'Zahler wählen' },
|
||||||
|
to_user: { en: 'To User', de: 'An Benutzer' },
|
||||||
|
select_recipient: { en: 'Select recipient', de: 'Empfänger wählen' },
|
||||||
|
settlement_amount_chf: { en: 'Settlement Amount (CHF)', de: 'Ausgleichsbetrag (CHF)' },
|
||||||
|
error_select_settlement: { en: 'Please select a settlement and enter an amount', de: 'Bitte einen Ausgleich wählen und Betrag eingeben' },
|
||||||
|
error_valid_amount: { en: 'Please enter a valid positive amount', de: 'Bitte einen gültigen positiven Betrag eingeben' },
|
||||||
|
settlement_payment: { en: 'Settlement Payment', de: 'Ausgleichszahlung' },
|
||||||
|
|
||||||
|
// Recurring page
|
||||||
|
recurring_subtitle: { en: 'Automate your regular shared expenses', de: 'Automatisiere deine regelmässigen geteilten Ausgaben' },
|
||||||
|
show_active_only: { en: 'Show active only', de: 'Nur aktive anzeigen' },
|
||||||
|
loading_recurring: { en: 'Loading recurring payments...', de: 'Wiederkehrende Zahlungen werden geladen...' },
|
||||||
|
no_recurring: { en: 'No recurring payments found', de: 'Keine wiederkehrenden Zahlungen gefunden' },
|
||||||
|
no_recurring_desc: { en: 'Create your first recurring payment to automate regular expenses like rent, utilities, or subscriptions.', de: 'Erstelle deine erste wiederkehrende Zahlung für regelmässige Ausgaben wie Miete, Nebenkosten oder Abos.' },
|
||||||
|
active: { en: 'Active', de: 'Aktiv' },
|
||||||
|
inactive: { en: 'Inactive', de: 'Inaktiv' },
|
||||||
|
frequency: { en: 'Frequency:', de: 'Häufigkeit:' },
|
||||||
|
next_execution: { en: 'Next execution:', de: 'Nächste Ausführung:' },
|
||||||
|
last_executed: { en: 'Last executed:', de: 'Zuletzt ausgeführt:' },
|
||||||
|
ends: { en: 'Ends:', de: 'Endet:' },
|
||||||
|
split_between: { en: 'Split between:', de: 'Aufgeteilt zwischen:' },
|
||||||
|
gets: { en: 'gets', de: 'bekommt' },
|
||||||
|
edit: { en: 'Edit', de: 'Bearbeiten' },
|
||||||
|
pause: { en: 'Pause', de: 'Pausieren' },
|
||||||
|
activate: { en: 'Activate', de: 'Aktivieren' },
|
||||||
|
delete_: { en: 'Delete', de: 'Löschen' },
|
||||||
|
delete_recurring_confirm: { en: 'Are you sure you want to delete the recurring payment', de: 'Wiederkehrende Zahlung wirklich löschen' },
|
||||||
|
|
||||||
|
// Shopping list
|
||||||
|
items_done: { en: 'done', de: 'erledigt' },
|
||||||
|
add_item_placeholder: { en: 'Add item...', de: 'Artikel hinzufügen...' },
|
||||||
|
empty_list: { en: 'The shopping list is empty', de: 'Die Einkaufsliste ist leer' },
|
||||||
|
clear_checked: { en: 'Remove checked', de: 'Erledigte entfernen' },
|
||||||
|
share: { en: 'Share', de: 'Teilen' },
|
||||||
|
|
||||||
|
// Share modal
|
||||||
|
shared_links: { en: 'Shared Links', de: 'Geteilte Links' },
|
||||||
|
share_desc: { en: 'Anyone with an active link can edit the shopping list.', de: 'Jeder mit einem aktiven Link kann die Einkaufsliste bearbeiten.' },
|
||||||
|
loading: { en: 'Loading...', de: 'Laden...' },
|
||||||
|
no_active_links: { en: 'No active links.', de: 'Keine aktiven Links.' },
|
||||||
|
remaining: { en: 'remaining', de: 'noch' },
|
||||||
|
change: { en: 'Change', de: 'Ändern' },
|
||||||
|
copy_link: { en: 'Copy link', de: 'Link kopieren' },
|
||||||
|
create_new_link: { en: 'Create new link', de: 'Neuen Link erstellen' },
|
||||||
|
copied: { en: 'Copied', de: 'Kopiert' },
|
||||||
|
expired: { en: 'expired', de: 'abgelaufen' },
|
||||||
|
|
||||||
|
// TTL
|
||||||
|
ttl_1h: { en: '1 hour', de: '1 Stunde' },
|
||||||
|
ttl_6h: { en: '6 hours', de: '6 Stunden' },
|
||||||
|
ttl_24h: { en: '24 hours', de: '24 Stunden' },
|
||||||
|
ttl_3d: { en: '3 days', de: '3 Tage' },
|
||||||
|
ttl_7d: { en: '7 days', de: '7 Tage' },
|
||||||
|
|
||||||
|
// Edit modal
|
||||||
|
kategorie: { en: 'Category', de: 'Kategorie' },
|
||||||
|
icon: { en: 'Icon', de: 'Icon' },
|
||||||
|
search_icon: { en: 'Search icon...', de: 'Icon suchen...' },
|
||||||
|
save: { en: 'Save', de: 'Speichern' },
|
||||||
|
saving: { en: 'Saving...', de: 'Speichern...' },
|
||||||
|
|
||||||
|
// EnhancedBalance
|
||||||
|
your_balance: { en: 'Your Balance', de: 'Dein Saldo' },
|
||||||
|
you_are_owed: { en: 'You are owed', de: 'Du bekommst' },
|
||||||
|
you_owe_balance: { en: 'You owe', de: 'Du schuldest' },
|
||||||
|
all_even: { en: "You're all even", de: 'Alles ausgeglichen' },
|
||||||
|
owes_you_balance: { en: 'owes you', de: 'schuldet dir' },
|
||||||
|
you_owe_user: { en: 'you owe', de: 'du schuldest' },
|
||||||
|
transaction: { en: 'transaction', de: 'Transaktion' },
|
||||||
|
transactions: { en: 'transactions', de: 'Transaktionen' },
|
||||||
|
|
||||||
|
// DebtBreakdown
|
||||||
|
debt_overview: { en: 'Debt Overview', de: 'Schuldenübersicht' },
|
||||||
|
loading_debt_breakdown: { en: 'Loading debt breakdown...', de: 'Schuldenübersicht wird geladen...' },
|
||||||
|
who_owes_you: { en: 'Who owes you', de: 'Wer dir schuldet' },
|
||||||
|
you_owe_section: { en: 'You owe', de: 'Du schuldest' },
|
||||||
|
total: { en: 'Total', de: 'Gesamt' },
|
||||||
|
|
||||||
|
// Frequency descriptions (recurring payments)
|
||||||
|
freq_every_day: { en: 'Every day', de: 'Jeden Tag' },
|
||||||
|
freq_every_week: { en: 'Every week', de: 'Jede Woche' },
|
||||||
|
freq_every_month: { en: 'Every month', de: 'Jeden Monat' },
|
||||||
|
freq_custom: { en: 'Custom', de: 'Benutzerdefiniert' },
|
||||||
|
freq_unknown: { en: 'Unknown frequency', de: 'Unbekannte Häufigkeit' },
|
||||||
|
|
||||||
|
// Next execution
|
||||||
|
today_at: { en: 'Today at', de: 'Heute um' },
|
||||||
|
tomorrow_at: { en: 'Tomorrow at', de: 'Morgen um' },
|
||||||
|
in_days_at: { en: 'In {days} days at', de: 'In {days} Tagen um' },
|
||||||
|
|
||||||
|
// UsersList
|
||||||
|
split_between_users: { en: 'Split Between Users', de: 'Aufteilen zwischen' },
|
||||||
|
predefined_note: { en: 'Splitting between predefined users:', de: 'Aufteilung zwischen vordefinierten Benutzern:' },
|
||||||
|
you: { en: 'You', de: 'Du' },
|
||||||
|
remove: { en: 'Remove', de: 'Entfernen' },
|
||||||
|
add_user_placeholder: { en: 'Add user...', de: 'Benutzer hinzufügen...' },
|
||||||
|
add_user: { en: 'Add User', de: 'Benutzer hinzufügen' },
|
||||||
|
|
||||||
|
// SplitMethodSelector
|
||||||
|
split_method: { en: 'Split Method', de: 'Aufteilungsmethode' },
|
||||||
|
how_split: { en: 'How should this payment be split?', de: 'Wie soll diese Zahlung aufgeteilt werden?' },
|
||||||
|
split_5050: { en: 'Split 50/50', de: '50/50 teilen' },
|
||||||
|
equal_split: { en: 'Equal Split', de: 'Gleichmässig' },
|
||||||
|
personal_equal_split: { en: 'Personal + Equal Split', de: 'Persönlich + Gleichmässig' },
|
||||||
|
custom_proportions: { en: 'Custom Proportions', de: 'Individuelle Anteile' },
|
||||||
|
custom_split_amounts: { en: 'Custom Split Amounts', de: 'Individuelle Beträge' },
|
||||||
|
personal_amounts: { en: 'Personal Amounts', de: 'Persönliche Beträge' },
|
||||||
|
personal_amounts_desc: { en: 'Enter personal amounts for each user. The remainder will be split equally.', de: 'Persönliche Beträge pro Benutzer eingeben. Der Rest wird gleichmässig aufgeteilt.' },
|
||||||
|
total_personal: { en: 'Total Personal', de: 'Persönlich gesamt' },
|
||||||
|
remainder_to_split: { en: 'Remainder to Split', de: 'Restbetrag zum Aufteilen' },
|
||||||
|
personal_exceeds_total: { en: 'Warning: Personal amounts exceed total payment amount!', de: 'Warnung: Persönliche Beträge übersteigen den Gesamtbetrag!' },
|
||||||
|
split_preview: { en: 'Split Preview', de: 'Aufteilungsvorschau' },
|
||||||
|
owes: { en: 'owes', de: 'schuldet' },
|
||||||
|
is_owed: { en: 'is owed', de: 'bekommt' },
|
||||||
|
error_prefix: { en: 'Error', de: 'Fehler' },
|
||||||
|
|
||||||
|
// Payment categories (for expense categories, not shopping)
|
||||||
|
cat_groceries: { en: 'Groceries', de: 'Lebensmittel' },
|
||||||
|
cat_shopping: { en: 'Shopping', de: 'Einkauf' },
|
||||||
|
cat_travel: { en: 'Travel', de: 'Reise' },
|
||||||
|
cat_restaurant: { en: 'Restaurant', de: 'Restaurant' },
|
||||||
|
cat_utilities: { en: 'Utilities', de: 'Nebenkosten' },
|
||||||
|
cat_fun: { en: 'Fun', de: 'Freizeit' },
|
||||||
|
cat_settlement: { en: 'Settlement', de: 'Ausgleich' },
|
||||||
|
|
||||||
|
// Payment add/edit forms
|
||||||
|
add_payment_title: { en: 'Add New Payment', de: 'Neue Zahlung' },
|
||||||
|
add_payment_subtitle: { en: 'Create a new shared expense or recurring payment', de: 'Neue geteilte Ausgabe oder wiederkehrende Zahlung erstellen' },
|
||||||
|
edit_payment_title: { en: 'Edit Payment', de: 'Zahlung bearbeiten' },
|
||||||
|
edit_payment_subtitle: { en: 'Modify payment details and receipt image', de: 'Zahlungsdetails und Beleg bearbeiten' },
|
||||||
|
edit_recurring_title: { en: 'Edit Recurring Payment', de: 'Wiederkehrende Zahlung bearbeiten' },
|
||||||
|
payment_details_section: { en: 'Payment Details', de: 'Zahlungsdetails' },
|
||||||
|
title_label: { en: 'Title *', de: 'Titel *' },
|
||||||
|
title_placeholder: { en: 'e.g., Dinner at restaurant', de: 'z.B. Abendessen im Restaurant' },
|
||||||
|
description_label: { en: 'Description', de: 'Beschreibung' },
|
||||||
|
description_placeholder: { en: 'Additional details...', de: 'Weitere Details...' },
|
||||||
|
category_star: { en: 'Category *', de: 'Kategorie *' },
|
||||||
|
amount_label: { en: 'Amount *', de: 'Betrag *' },
|
||||||
|
payment_date: { en: 'Payment Date', de: 'Zahlungsdatum' },
|
||||||
|
paid_by_form: { en: 'Paid by', de: 'Bezahlt von' },
|
||||||
|
make_recurring: { en: 'Make this a recurring payment', de: 'Als wiederkehrende Zahlung einrichten' },
|
||||||
|
recurring_section: { en: 'Recurring Payment', de: 'Wiederkehrende Zahlung' },
|
||||||
|
recurring_schedule: { en: 'Recurring Schedule', de: 'Wiederkehrender Zeitplan' },
|
||||||
|
frequency_label: { en: 'Frequency *', de: 'Häufigkeit *' },
|
||||||
|
freq_daily: { en: 'Daily', de: 'Täglich' },
|
||||||
|
freq_weekly: { en: 'Weekly', de: 'Wöchentlich' },
|
||||||
|
freq_monthly: { en: 'Monthly', de: 'Monatlich' },
|
||||||
|
freq_quarterly: { en: 'Quarterly', de: 'Vierteljährlich' },
|
||||||
|
freq_yearly: { en: 'Yearly', de: 'Jährlich' },
|
||||||
|
freq_custom: { en: 'Custom (Cron)', de: 'Benutzerdefiniert (Cron)' },
|
||||||
|
start_date: { en: 'Start Date *', de: 'Startdatum *' },
|
||||||
|
end_date_optional: { en: 'End Date (optional)', de: 'Enddatum (optional)' },
|
||||||
|
end_date_hint: { en: 'Leave empty for indefinite recurring', de: 'Leer lassen für unbefristete Wiederholung' },
|
||||||
|
next_execution_preview: { en: 'Next Execution', de: 'Nächste Ausführung' },
|
||||||
|
status_label: { en: 'Status', de: 'Status' },
|
||||||
|
create_payment: { en: 'Create payment', de: 'Zahlung erstellen' },
|
||||||
|
save_changes: { en: 'Save changes', de: 'Änderungen speichern' },
|
||||||
|
delete_payment: { en: 'Delete Payment', de: 'Zahlung löschen' },
|
||||||
|
deleting: { en: 'Deleting...', de: 'Löschen...' },
|
||||||
|
|
||||||
|
// Split configuration (edit page)
|
||||||
|
split_config: { en: 'Split Configuration', de: 'Aufteilungskonfiguration' },
|
||||||
|
split_method_form: { en: 'Split Method:', de: 'Aufteilungsart:' },
|
||||||
|
equal_split: { en: 'Equal Split', de: 'Gleichmässige Aufteilung' },
|
||||||
|
personal_equal_split: { en: 'Personal + Equal Split', de: 'Persönliche Beträge + Gleichverteilung' },
|
||||||
|
custom_proportions: { en: 'Custom Proportions', de: 'Individuelle Anteile' },
|
||||||
|
personal_amounts: { en: 'Personal Amounts', de: 'Persönliche Beträge' },
|
||||||
|
personal_amounts_desc: { en: 'Enter personal amounts for each user. The remainder will be split equally.', de: 'Persönliche Beträge für jeden Benutzer eingeben. Der Rest wird gleichmässig aufgeteilt.' },
|
||||||
|
total_personal: { en: 'Total Personal', de: 'Persönliche Summe' },
|
||||||
|
remainder_to_split: { en: 'Remainder to Split', de: 'Rest zum Aufteilen' },
|
||||||
|
personal_exceeds: { en: 'Personal amounts exceed total payment amount!', de: 'Persönliche Beträge übersteigen den Gesamtbetrag!' },
|
||||||
|
split_preview: { en: 'Split Preview', de: 'Aufteilungsvorschau' },
|
||||||
|
|
||||||
|
// Currency conversion
|
||||||
|
conversion_hint: { en: 'Amount will be converted to CHF using exchange rates for the payment date', de: 'Betrag wird anhand des Wechselkurses am Zahlungstag in CHF umgerechnet' },
|
||||||
|
fetching_rate: { en: 'Fetching exchange rate...', de: 'Wechselkurs wird abgerufen...' },
|
||||||
|
exchange_rate_date: { en: 'Exchange rate will be fetched for this date', de: 'Wechselkurs wird für dieses Datum abgerufen' },
|
||||||
|
|
||||||
|
// SplitMethodSelector
|
||||||
|
paid_in_full: { en: 'Paid in Full', de: 'Vollständig bezahlt' },
|
||||||
|
paid_in_full_for: { en: 'Paid in Full for', de: 'Vollständig bezahlt für' },
|
||||||
|
paid_in_full_by_you: { en: 'Paid in Full by You', de: 'Vollständig von dir bezahlt' },
|
||||||
|
paid_in_full_by: { en: 'Paid in Full by', de: 'Vollständig bezahlt von' },
|
||||||
|
|
||||||
|
// Shopping category names (for EN display)
|
||||||
|
cat_fruits_veg: { en: 'Fruits & Vegetables', de: 'Obst & Gemüse' },
|
||||||
|
cat_meat_fish: { en: 'Meat & Fish', de: 'Fleisch & Fisch' },
|
||||||
|
cat_dairy: { en: 'Dairy', de: 'Milchprodukte' },
|
||||||
|
cat_bakery: { en: 'Bread & Bakery', de: 'Brot & Backwaren' },
|
||||||
|
cat_grains: { en: 'Pasta, Rice & Grains', de: 'Pasta, Reis & Getreide' },
|
||||||
|
cat_spices: { en: 'Spices & Sauces', de: 'Gewürze & Saucen' },
|
||||||
|
cat_drinks: { en: 'Beverages', de: 'Getränke' },
|
||||||
|
cat_sweets: { en: 'Sweets & Snacks', de: 'Süßes & Snacks' },
|
||||||
|
cat_frozen: { en: 'Frozen', de: 'Tiefkühl' },
|
||||||
|
cat_household: { en: 'Household', de: 'Haushalt' },
|
||||||
|
cat_hygiene: { en: 'Hygiene & Body Care', de: 'Hygiene & Körperpflege' },
|
||||||
|
cat_other: { en: 'Other', de: 'Sonstiges' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Category name translation map (German key → display name per language) */
|
||||||
|
const categoryDisplayNames: Record<string, Record<string, string>> = {
|
||||||
|
'Obst & Gemüse': { en: 'Fruits & Vegetables', de: 'Obst & Gemüse' },
|
||||||
|
'Fleisch & Fisch': { en: 'Meat & Fish', de: 'Fleisch & Fisch' },
|
||||||
|
'Milchprodukte': { en: 'Dairy', de: 'Milchprodukte' },
|
||||||
|
'Brot & Backwaren': { en: 'Bread & Bakery', de: 'Brot & Backwaren' },
|
||||||
|
'Pasta, Reis & Getreide': { en: 'Pasta, Rice & Grains', de: 'Pasta, Reis & Getreide' },
|
||||||
|
'Gewürze & Saucen': { en: 'Spices & Sauces', de: 'Gewürze & Saucen' },
|
||||||
|
'Getränke': { en: 'Beverages', de: 'Getränke' },
|
||||||
|
'Süßes & Snacks': { en: 'Sweets & Snacks', de: 'Süßes & Snacks' },
|
||||||
|
'Tiefkühl': { en: 'Frozen', de: 'Tiefkühl' },
|
||||||
|
'Haushalt': { en: 'Household', de: 'Haushalt' },
|
||||||
|
'Hygiene & Körperpflege': { en: 'Hygiene & Body Care', de: 'Hygiene & Körperpflege' },
|
||||||
|
'Sonstiges': { en: 'Other', de: 'Sonstiges' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Get translated category display name (shopping categories) */
|
||||||
|
export function categoryName(category: string, lang: 'en' | 'de'): string {
|
||||||
|
return categoryDisplayNames[category]?.[lang] ?? category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payment category translation map */
|
||||||
|
const paymentCategoryNames: Record<string, Record<string, string>> = {
|
||||||
|
groceries: { en: 'Groceries', de: 'Lebensmittel' },
|
||||||
|
shopping: { en: 'Shopping', de: 'Einkauf' },
|
||||||
|
travel: { en: 'Travel', de: 'Reise' },
|
||||||
|
restaurant: { en: 'Restaurant', de: 'Restaurant' },
|
||||||
|
utilities: { en: 'Utilities', de: 'Nebenkosten' },
|
||||||
|
fun: { en: 'Fun', de: 'Freizeit' },
|
||||||
|
settlement: { en: 'Settlement', de: 'Ausgleich' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Get translated payment category name */
|
||||||
|
export function paymentCategoryName(category: string, lang: 'en' | 'de'): string {
|
||||||
|
return paymentCategoryNames[category]?.[lang] ?? category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get category options with translated labels */
|
||||||
|
export function getCategoryOptionsI18n(lang: 'en' | 'de') {
|
||||||
|
const emojis: Record<string, string> = {
|
||||||
|
groceries: '🛒', shopping: '🛍️', travel: '🚆',
|
||||||
|
restaurant: '🍽️', utilities: '⚡', fun: '🎉', settlement: '🤝'
|
||||||
|
};
|
||||||
|
return Object.keys(paymentCategoryNames).map(key => ({
|
||||||
|
value: key,
|
||||||
|
label: `${emojis[key] || ''} ${paymentCategoryName(key, lang)}`,
|
||||||
|
emoji: emojis[key] || '',
|
||||||
|
name: paymentCategoryName(key, lang)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a translated string */
|
||||||
|
export function t(key: string, lang: 'en' | 'de'): string {
|
||||||
|
return translations[key]?.[lang] ?? translations[key]?.en ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format TTL remaining time in the target language */
|
||||||
|
export function formatTTL(expiresAt: string, lang: 'en' | 'de'): string {
|
||||||
|
const diff = new Date(expiresAt).getTime() - Date.now();
|
||||||
|
if (diff <= 0) return t('expired', lang);
|
||||||
|
const mins = Math.round(diff / 60000);
|
||||||
|
if (mins < 60) return `${mins} min`;
|
||||||
|
const hours = Math.round(diff / 3600000);
|
||||||
|
if (hours < 24) return `${hours} ${lang === 'en' ? 'hrs' : 'Std.'}`;
|
||||||
|
const days = Math.round(diff / 86400000);
|
||||||
|
return `${days} ${lang === 'en' ? (days > 1 ? 'days' : 'day') : (days > 1 ? 'Tage' : 'Tag')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get TTL options for the given language */
|
||||||
|
export function ttlOptions(lang: 'en' | 'de') {
|
||||||
|
return [
|
||||||
|
{ label: t('ttl_1h', lang), ms: 1 * 60 * 60 * 1000 },
|
||||||
|
{ label: t('ttl_6h', lang), ms: 6 * 60 * 60 * 1000 },
|
||||||
|
{ label: t('ttl_24h', lang), ms: 24 * 60 * 60 * 1000 },
|
||||||
|
{ label: t('ttl_3d', lang), ms: 3 * 24 * 60 * 60 * 1000 },
|
||||||
|
{ label: t('ttl_7d', lang), ms: 7 * 24 * 60 * 60 * 1000 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get locale string for number/date formatting */
|
||||||
|
export function locale(lang: 'en' | 'de'): string {
|
||||||
|
return lang === 'en' ? 'en-CH' : 'de-CH';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a split description string */
|
||||||
|
export function splitDescription(payment: { splits?: any[]; splitMethod?: string; paidBy?: string }, lang: 'en' | 'de'): string {
|
||||||
|
if (!payment.splits || payment.splits.length === 0) return t('no_splits', lang);
|
||||||
|
|
||||||
|
const count = payment.splits.length;
|
||||||
|
if (payment.splitMethod === 'equal') {
|
||||||
|
return `${t('split_equal', lang)} ${count} ${t('people', lang)}`;
|
||||||
|
} else if (payment.splitMethod === 'full') {
|
||||||
|
return `${t('paid_full_by', lang)} ${payment.paidBy}`;
|
||||||
|
} else if (payment.splitMethod === 'personal_equal') {
|
||||||
|
return `${t('personal_equal', lang)} ${count} ${t('people', lang)}`;
|
||||||
|
} else {
|
||||||
|
return `${t('custom_split', lang)} ${count} ${t('people', lang)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get translated frequency description for a recurring payment */
|
||||||
|
export function frequencyDescription(payment: { frequency: string; cronExpression?: string }, lang: 'en' | 'de'): string {
|
||||||
|
switch (payment.frequency) {
|
||||||
|
case 'daily': return t('freq_every_day', lang);
|
||||||
|
case 'weekly': return t('freq_every_week', lang);
|
||||||
|
case 'monthly': return t('freq_every_month', lang);
|
||||||
|
case 'custom': return `${t('freq_custom', lang)}: ${payment.cronExpression}`;
|
||||||
|
default: return t('freq_unknown', lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format next execution date with i18n */
|
||||||
|
export function formatNextExecutionI18n(date: Date, lang: 'en' | 'de'): string {
|
||||||
|
const loc = locale(lang);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = date.getTime() - now.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
const timeStr = date.toLocaleTimeString(loc, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return `${t('today_at', lang)} ${timeStr}`;
|
||||||
|
} else if (diffDays === 1) {
|
||||||
|
return `${t('tomorrow_at', lang)} ${timeStr}`;
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return `${t('in_days_at', lang).replace('{days}', String(diffDays))} ${timeStr}`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleString(loc, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/params/cospendRoot.ts
Normal file
5
src/params/cospendRoot.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { ParamMatcher } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const match: ParamMatcher = (param) => {
|
||||||
|
return param === 'cospend' || param === 'expenses';
|
||||||
|
};
|
||||||
9
src/routes/[cospendRoot=cospendRoot]/+layout.server.ts
Normal file
9
src/routes/[cospendRoot=cospendRoot]/+layout.server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { LayoutServerLoad } from "./$types"
|
||||||
|
import { detectCospendLang } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
|
export const load : LayoutServerLoad = async ({locals, url}) => {
|
||||||
|
return {
|
||||||
|
session: locals.session ?? await locals.auth(),
|
||||||
|
lang: detectCospendLang(url.pathname) as 'en' | 'de'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,10 +7,16 @@
|
|||||||
import PaymentModal from '$lib/components/cospend/PaymentModal.svelte';
|
import PaymentModal from '$lib/components/cospend/PaymentModal.svelte';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
import UserHeader from '$lib/components/UserHeader.svelte';
|
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||||
|
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||||
import { LayoutDashboard, Wallet, RefreshCw, ShoppingCart } from '@lucide/svelte';
|
import { LayoutDashboard, Wallet, RefreshCw, ShoppingCart } from '@lucide/svelte';
|
||||||
|
import { detectCospendLang, cospendRoot, cospendLabels } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const root = $derived(cospendRoot(lang));
|
||||||
|
const labels = $derived(cospendLabels(lang));
|
||||||
|
|
||||||
let showModal = $state(false);
|
let showModal = $state(false);
|
||||||
/** @type {string | null} */
|
/** @type {string | null} */
|
||||||
let paymentId = $state(null);
|
let paymentId = $state(null);
|
||||||
@@ -19,14 +25,14 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Check if URL contains payment view route OR if we have paymentId in state
|
// Check if URL contains payment view route OR if we have paymentId in state
|
||||||
const match = $page.url.pathname.match(/\/cospend\/payments\/view\/([^\/]+)/);
|
const match = $page.url.pathname.match(/\/(cospend|expenses)\/payments\/view\/([^\/]+)/);
|
||||||
const statePaymentId = $page.state?.paymentId;
|
const statePaymentId = $page.state?.paymentId;
|
||||||
const isOnDashboard = $page.route.id === '/cospend/dash';
|
const isOnDashboard = $page.route.id === '/[cospendRoot=cospendRoot]/dash';
|
||||||
|
|
||||||
// Only show modal if we're on the dashboard AND have a payment to show
|
// Only show modal if we're on the dashboard AND have a payment to show
|
||||||
if (isOnDashboard && (match || statePaymentId)) {
|
if (isOnDashboard && (match || statePaymentId)) {
|
||||||
showModal = true;
|
showModal = true;
|
||||||
paymentId = match ? match[1] : statePaymentId ?? null;
|
paymentId = match ? match[2] : statePaymentId ?? null;
|
||||||
} else {
|
} else {
|
||||||
showModal = false;
|
showModal = false;
|
||||||
paymentId = null;
|
paymentId = null;
|
||||||
@@ -39,7 +45,7 @@
|
|||||||
paymentId = null;
|
paymentId = null;
|
||||||
|
|
||||||
// Dispatch a custom event to trigger dashboard refresh
|
// Dispatch a custom event to trigger dashboard refresh
|
||||||
if ($page.route.id === '/cospend/dash') {
|
if ($page.route.id === '/[cospendRoot=cospendRoot]/dash') {
|
||||||
window.dispatchEvent(new CustomEvent('dashboardRefresh'));
|
window.dispatchEvent(new CustomEvent('dashboardRefresh'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,9 +53,9 @@
|
|||||||
/** @param {string} path */
|
/** @param {string} path */
|
||||||
function isActive(path) {
|
function isActive(path) {
|
||||||
const currentPath = $page.url.pathname;
|
const currentPath = $page.url.pathname;
|
||||||
// Exact match for cospend root
|
// Exact match for dash
|
||||||
if (path === '/cospend/dash') {
|
if (path.endsWith('/dash')) {
|
||||||
return currentPath === '/cospend/dash' || currentPath === '/cospend/dash/';
|
return currentPath === path || currentPath === path + '/';
|
||||||
}
|
}
|
||||||
// For other paths, check if current path starts with the link path
|
// For other paths, check if current path starts with the link path
|
||||||
return currentPath.startsWith(path);
|
return currentPath.startsWith(path);
|
||||||
@@ -60,17 +66,18 @@
|
|||||||
{#snippet links()}
|
{#snippet links()}
|
||||||
<ul class="site_header">
|
<ul class="site_header">
|
||||||
{#if !isGuest}
|
{#if !isGuest}
|
||||||
<li style="--active-fill: var(--nord9)"><a href="/cospend/dash" class:active={isActive('/cospend/dash')}><LayoutDashboard size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Dashboard</span></a></li>
|
<li style="--active-fill: var(--nord9)"><a href="/{root}/dash" class:active={isActive(`/${root}/dash`)}><LayoutDashboard size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.dash}</span></a></li>
|
||||||
{/if}
|
{/if}
|
||||||
<li style="--active-fill: var(--nord13)"><a href="/cospend/list" class:active={isActive('/cospend/list')}><ShoppingCart size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Liste</span></a></li>
|
<li style="--active-fill: var(--nord13)"><a href="/{root}/list" class:active={isActive(`/${root}/list`)}><ShoppingCart size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.list}</span></a></li>
|
||||||
{#if !isGuest}
|
{#if !isGuest}
|
||||||
<li style="--active-fill: var(--nord14)"><a href="/cospend/payments" class:active={isActive('/cospend/payments')}><span class="nav-icon-wrap nav-icon-wallet"><Wallet size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">All Payments</span></a></li>
|
<li style="--active-fill: var(--nord14)"><a href="/{root}/payments" class:active={isActive(`/${root}/payments`)}><span class="nav-icon-wrap nav-icon-wallet"><Wallet size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">{labels.payments}</span></a></li>
|
||||||
<li style="--active-fill: var(--nord12); --active-shape: circle(50%)"><a href="/cospend/recurring" class:active={isActive('/cospend/recurring')}><span class="nav-icon-wrap"><RefreshCw size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">Recurring</span></a></li>
|
<li style="--active-fill: var(--nord12); --active-shape: circle(50%)"><a href="/{root}/recurring" class:active={isActive(`/${root}/recurring`)}><span class="nav-icon-wrap"><RefreshCw size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">{labels.recurring}</span></a></li>
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet right_side()}
|
{#snippet right_side()}
|
||||||
|
<LanguageSelector lang={lang} />
|
||||||
<UserHeader {user}></UserHeader>
|
<UserHeader {user}></UserHeader>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
7
src/routes/[cospendRoot=cospendRoot]/+page.server.ts
Normal file
7
src/routes/[cospendRoot=cospendRoot]/+page.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { detectCospendLang, cospendRoot } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
|
export function load({ url }) {
|
||||||
|
const lang = detectCospendLang(url.pathname);
|
||||||
|
redirect(302, `/${cospendRoot(lang)}/list`);
|
||||||
|
}
|
||||||
@@ -7,14 +7,18 @@
|
|||||||
import EnhancedBalance from '$lib/components/cospend/EnhancedBalance.svelte';
|
import EnhancedBalance from '$lib/components/cospend/EnhancedBalance.svelte';
|
||||||
import DebtBreakdown from '$lib/components/cospend/DebtBreakdown.svelte';
|
import DebtBreakdown from '$lib/components/cospend/DebtBreakdown.svelte';
|
||||||
import BarChart from '$lib/components/cospend/BarChart.svelte';
|
import BarChart from '$lib/components/cospend/BarChart.svelte';
|
||||||
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
|
import { getCategoryEmoji } from '$lib/utils/categories';
|
||||||
import { isSettlementPayment, getSettlementIcon, getSettlementClasses, getSettlementReceiver } from '$lib/utils/settlements';
|
import { isSettlementPayment, getSettlementIcon, getSettlementClasses, getSettlementReceiver } from '$lib/utils/settlements';
|
||||||
import AddButton from '$lib/components/AddButton.svelte';
|
import AddButton from '$lib/components/AddButton.svelte';
|
||||||
|
|
||||||
|
|
||||||
import { formatCurrency } from '$lib/utils/formatters';
|
import { formatCurrency } from '$lib/utils/formatters';
|
||||||
|
import { detectCospendLang, cospendRoot, t, locale, paymentCategoryName } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
let { data } = $props(); // Contains session data and balance from server
|
let { data } = $props(); // Contains session data and balance from server
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const root = $derived(cospendRoot(lang));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
// Use server-side data, with fallback for progressive enhancement
|
// Use server-side data, with fallback for progressive enhancement
|
||||||
let balance = $derived(data.balance || {
|
let balance = $derived(data.balance || {
|
||||||
@@ -79,7 +83,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(/** @type {string} */ dateString) {
|
function formatDate(/** @type {string} */ dateString) {
|
||||||
return new Date(dateString).toLocaleDateString('de-CH');
|
return new Date(dateString).toLocaleDateString(loc);
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateDescription(/** @type {string} */ description, maxLength = 100) {
|
function truncateDescription(/** @type {string} */ description, maxLength = 100) {
|
||||||
@@ -92,7 +96,7 @@
|
|||||||
// Progressive enhancement: if JavaScript is available, use pushState for modal behavior
|
// Progressive enhancement: if JavaScript is available, use pushState for modal behavior
|
||||||
if (typeof pushState !== 'undefined') {
|
if (typeof pushState !== 'undefined') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
pushState(`/cospend/payments/view/${paymentId}`, { paymentId });
|
pushState(`/${root}/payments/view/${paymentId}`, { paymentId });
|
||||||
}
|
}
|
||||||
// Otherwise, let the regular link navigation happen (no preventDefault)
|
// Otherwise, let the regular link navigation happen (no preventDefault)
|
||||||
}
|
}
|
||||||
@@ -120,11 +124,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Cospend - Expense Sharing</title>
|
<title>{t('cospend_title', lang)}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="cospend-main">
|
<main class="cospend-main">
|
||||||
<h1>Cospend</h1>
|
<h1>{t('cospend', lang)}</h1>
|
||||||
|
|
||||||
<!-- Responsive layout for balance and chart -->
|
<!-- Responsive layout for balance and chart -->
|
||||||
<div class="dashboard-layout">
|
<div class="dashboard-layout">
|
||||||
@@ -133,7 +137,7 @@
|
|||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if balance.netBalance !== 0}
|
{#if balance.netBalance !== 0}
|
||||||
<a href="/cospend/settle" class="btn btn-settlement">Settle Debts</a>
|
<a href="/{root}/settle" class="btn btn-settlement">{t('settle_debts', lang)}</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,12 +147,13 @@
|
|||||||
<!-- Monthly Expenses Chart -->
|
<!-- Monthly Expenses Chart -->
|
||||||
<div class="chart-section">
|
<div class="chart-section">
|
||||||
{#if expensesLoading}
|
{#if expensesLoading}
|
||||||
<div class="loading">Loading monthly expenses chart...</div>
|
<div class="loading">{t('loading_monthly', lang)}</div>
|
||||||
{:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0}
|
{:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0}
|
||||||
<BarChart
|
<BarChart
|
||||||
data={monthlyExpensesData}
|
data={monthlyExpensesData}
|
||||||
title="Monthly Expenses by Category"
|
title={t('monthly_expenses_chart', lang)}
|
||||||
height="400px"
|
height="400px"
|
||||||
|
{lang}
|
||||||
onFilterChange={(/** @type {string[] | null} */ categories) => categoryFilter = categories}
|
onFilterChange={(/** @type {string[] | null} */ categories) => categoryFilter = categories}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -162,26 +167,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading recent activity...</div>
|
<div class="loading">{t('loading_recent', lang)}</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">Error: {error}</div>
|
||||||
{:else if balance.recentSplits && balance.recentSplits.length > 0}
|
{:else if balance.recentSplits && balance.recentSplits.length > 0}
|
||||||
<div class="recent-activity">
|
<div class="recent-activity">
|
||||||
<div class="recent-activity-header">
|
<div class="recent-activity-header">
|
||||||
<h2>Recent Activity{#if categoryFilter} <span class="filter-label">— {categoryFilter.map((/** @type {any} */ c) => getCategoryName(c)).join(', ')}</span>{/if}</h2>
|
<h2>{t('recent_activity', lang)}{#if categoryFilter} <span class="filter-label">— {categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ')}</span>{/if}</h2>
|
||||||
{#if categoryFilter}
|
{#if categoryFilter}
|
||||||
<button class="clear-filter" onclick={() => categoryFilter = null}>Clear filter</button>
|
<button class="clear-filter" onclick={() => categoryFilter = null}>{t('clear_filter', lang)}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if filteredSplits.length === 0}
|
{#if filteredSplits.length === 0}
|
||||||
<p class="no-results">No recent activity in {categoryFilter ? categoryFilter.map((/** @type {any} */ c) => getCategoryName(c)).join(', ') : ''}.</p>
|
<p class="no-results">{t('no_recent_in', lang)} {categoryFilter ? categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ') : ''}.</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="activity-dialog">
|
<div class="activity-dialog">
|
||||||
{#each filteredSplits as split}
|
{#each filteredSplits as split}
|
||||||
{#if isSettlementPayment(split.paymentId)}
|
{#if isSettlementPayment(split.paymentId)}
|
||||||
<!-- Settlement Payment Display - User -> User Flow -->
|
<!-- Settlement Payment Display - User -> User Flow -->
|
||||||
<a
|
<a
|
||||||
href="/cospend/payments/view/{split.paymentId?._id}"
|
href="/{root}/payments/view/{split.paymentId?._id}"
|
||||||
class="settlement-flow-activity"
|
class="settlement-flow-activity"
|
||||||
onclick={(e) => handlePaymentClick(split.paymentId?._id, e)}
|
onclick={(e) => handlePaymentClick(split.paymentId?._id, e)}
|
||||||
>
|
>
|
||||||
@@ -193,7 +198,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="settlement-arrow-section">
|
<div class="settlement-arrow-section">
|
||||||
<div class="settlement-amount-large">
|
<div class="settlement-amount-large">
|
||||||
{formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
|
{formatCurrency(Math.abs(split.amount), 'CHF', loc)}
|
||||||
</div>
|
</div>
|
||||||
<div class="settlement-flow-arrow">→</div>
|
<div class="settlement-flow-arrow">→</div>
|
||||||
<div class="settlement-date">{formatDate(split.paymentId?.date || split.paymentId?.createdAt)}</div>
|
<div class="settlement-date">{formatDate(split.paymentId?.date || split.paymentId?.createdAt)}</div>
|
||||||
@@ -212,7 +217,7 @@
|
|||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<ProfilePicture username={split.paymentId?.paidBy || 'Unknown'} size={36} />
|
<ProfilePicture username={split.paymentId?.paidBy || 'Unknown'} size={36} />
|
||||||
<a
|
<a
|
||||||
href="/cospend/payments/view/{split.paymentId?._id}"
|
href="/{root}/payments/view/{split.paymentId?._id}"
|
||||||
class="activity-bubble"
|
class="activity-bubble"
|
||||||
onclick={(e) => handlePaymentClick(split.paymentId?._id, e)}
|
onclick={(e) => handlePaymentClick(split.paymentId?._id, e)}
|
||||||
>
|
>
|
||||||
@@ -220,20 +225,20 @@
|
|||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="payment-title-row">
|
<div class="payment-title-row">
|
||||||
<span class="category-emoji">{getCategoryEmoji(split.paymentId?.category || 'groceries')}</span>
|
<span class="category-emoji">{getCategoryEmoji(split.paymentId?.category || 'groceries')}</span>
|
||||||
<strong class="payment-title">{split.paymentId?.title || 'Payment'}</strong>
|
<strong class="payment-title">{split.paymentId?.title || t('payment', lang)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<span class="username">Paid by {split.paymentId?.paidBy || 'Unknown'}</span>
|
<span class="username">{t('paid_by', lang)} {split.paymentId?.paidBy || 'Unknown'}</span>
|
||||||
<span class="category-name">{getCategoryName(split.paymentId?.category || 'groceries')}</span>
|
<span class="category-name">{paymentCategoryName(split.paymentId?.category || 'groceries', lang)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-amount"
|
<div class="activity-amount"
|
||||||
class:positive={split.amount < 0}
|
class:positive={split.amount < 0}
|
||||||
class:negative={split.amount > 0}>
|
class:negative={split.amount > 0}>
|
||||||
{#if split.amount > 0}
|
{#if split.amount > 0}
|
||||||
-{formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
|
-{formatCurrency(Math.abs(split.amount), 'CHF', loc)}
|
||||||
{:else if split.amount < 0}
|
{:else if split.amount < 0}
|
||||||
+{formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
|
+{formatCurrency(Math.abs(split.amount), 'CHF', loc)}
|
||||||
{:else}
|
{:else}
|
||||||
{formatCurrency(split.amount, 'CHF', 'de-CH')}
|
{formatCurrency(split.amount, 'CHF', loc)}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,7 +262,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AddButton href="/cospend/payments/add" />
|
<AddButton href="/{root}/payments/add" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.cospend-main {
|
.cospend-main {
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
import iconCategoriesData from '$lib/data/shoppingIconCategories.json';
|
import iconCategoriesData from '$lib/data/shoppingIconCategories.json';
|
||||||
|
|
||||||
import { Share2, X, Copy, Check } from '@lucide/svelte';
|
import { Share2, X, Copy, Check } from '@lucide/svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { detectCospendLang, t, locale, categoryName, formatTTL as formatTTLi18n, ttlOptions } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let user = $derived(data.session?.user?.nickname || 'guest');
|
let user = $derived(data.session?.user?.nickname || 'guest');
|
||||||
@@ -18,6 +20,9 @@
|
|||||||
let isGuest = $derived(!data.session);
|
let isGuest = $derived(!data.session);
|
||||||
const sync = getShoppingSync();
|
const sync = getShoppingSync();
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
/** @type {Record<string, { icon: typeof Plus, color: string }>} */
|
/** @type {Record<string, { icon: typeof Plus, color: string }>} */
|
||||||
const categoryMeta = {
|
const categoryMeta = {
|
||||||
'Obst & Gemüse': { icon: Apple, color: 'var(--nord14)' },
|
'Obst & Gemüse': { icon: Apple, color: 'var(--nord14)' },
|
||||||
@@ -266,23 +271,10 @@
|
|||||||
|
|
||||||
/** @param {string} expiresAt */
|
/** @param {string} expiresAt */
|
||||||
function formatTTL(expiresAt) {
|
function formatTTL(expiresAt) {
|
||||||
const diff = new Date(expiresAt).getTime() - Date.now();
|
return formatTTLi18n(expiresAt, lang);
|
||||||
if (diff <= 0) return 'abgelaufen';
|
|
||||||
const mins = Math.round(diff / 60000);
|
|
||||||
if (mins < 60) return `${mins} Min.`;
|
|
||||||
const hours = Math.round(diff / 3600000);
|
|
||||||
if (hours < 24) return `${hours} Std.`;
|
|
||||||
const days = Math.round(diff / 86400000);
|
|
||||||
return `${days} Tag${days > 1 ? 'e' : ''}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTL_OPTIONS = [
|
let TTL_OPTIONS = $derived(ttlOptions(lang));
|
||||||
{ label: '1 Stunde', ms: 1 * 60 * 60 * 1000 },
|
|
||||||
{ label: '6 Stunden', ms: 6 * 60 * 60 * 1000 },
|
|
||||||
{ label: '24 Stunden', ms: 24 * 60 * 60 * 1000 },
|
|
||||||
{ label: '3 Tage', ms: 3 * 24 * 60 * 60 * 1000 },
|
|
||||||
{ label: '7 Tage', ms: 7 * 24 * 60 * 60 * 1000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
@@ -303,12 +295,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {{ id: string, token: string }} t */
|
/** @param {{ id: string, token: string }} tok */
|
||||||
async function copyTokenLink(t) {
|
async function copyTokenLink(tok) {
|
||||||
const url = new URL('/cospend/list', window.location.origin);
|
const root = $page.url.pathname.split('/')[1];
|
||||||
url.searchParams.set('token', t.token);
|
const url = new URL(`/${root}/list`, window.location.origin);
|
||||||
|
url.searchParams.set('token', tok.token);
|
||||||
await navigator.clipboard.writeText(url.toString());
|
await navigator.clipboard.writeText(url.toString());
|
||||||
copiedId = t.id;
|
copiedId = tok.id;
|
||||||
showCopyToast = true;
|
showCopyToast = true;
|
||||||
setTimeout(() => { copiedId = null; showCopyToast = false; }, 2000);
|
setTimeout(() => { copiedId = null; showCopyToast = false; }, 2000);
|
||||||
}
|
}
|
||||||
@@ -321,7 +314,7 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id })
|
body: JSON.stringify({ id })
|
||||||
});
|
});
|
||||||
shareTokens = shareTokens.filter(t => t.id !== id);
|
shareTokens = shareTokens.filter(tok => tok.id !== id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[shopping] Delete token error:', err);
|
console.error('[shopping] Delete token error:', err);
|
||||||
}
|
}
|
||||||
@@ -338,8 +331,8 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, expiresAt: newExpiry })
|
body: JSON.stringify({ id, expiresAt: newExpiry })
|
||||||
});
|
});
|
||||||
shareTokens = shareTokens.map(t =>
|
shareTokens = shareTokens.map(tok =>
|
||||||
t.id === id ? { ...t, expiresAt: newExpiry } : t
|
tok.id === id ? { ...tok, expiresAt: newExpiry } : tok
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[shopping] Update token error:', err);
|
console.error('[shopping] Update token error:', err);
|
||||||
@@ -369,15 +362,15 @@
|
|||||||
<div class="shopping-page">
|
<div class="shopping-page">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<h1>Einkaufsliste <SyncIndicator status={sync.status} /></h1>
|
<h1>{t('shopping_list_title', lang)} <SyncIndicator status={sync.status} /></h1>
|
||||||
{#if !isGuest}
|
{#if !isGuest}
|
||||||
<button class="btn-share" onclick={openShareModal} title="Teilen">
|
<button class="btn-share" onclick={openShareModal} title={t('share', lang)}>
|
||||||
<Share2 size={16} />
|
<Share2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if totalCount > 0}
|
{#if totalCount > 0}
|
||||||
<p class="subtitle">{checkedCount} / {totalCount} erledigt</p>
|
<p class="subtitle">{checkedCount} / {totalCount} {t('items_done', lang)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="store-picker">
|
<div class="store-picker">
|
||||||
<Store size={13} />
|
<Store size={13} />
|
||||||
@@ -397,7 +390,7 @@
|
|||||||
bind:value={newItemName}
|
bind:value={newItemName}
|
||||||
onkeydown={onKeydown}
|
onkeydown={onKeydown}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Artikel hinzufügen..."
|
placeholder={t('add_item_placeholder', lang)}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<button class="btn-add" onclick={addItem} disabled={!newItemName.trim()}>
|
<button class="btn-add" onclick={addItem} disabled={!newItemName.trim()}>
|
||||||
@@ -406,7 +399,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if totalCount === 0}
|
{#if totalCount === 0}
|
||||||
<p class="empty-state">Die Einkaufsliste ist leer</p>
|
<p class="empty-state">{t('empty_list', lang)}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="item-list">
|
<div class="item-list">
|
||||||
{#each groupedItems as group (group.category)}
|
{#each groupedItems as group (group.category)}
|
||||||
@@ -418,7 +411,7 @@
|
|||||||
<div class="category-icon">
|
<div class="category-icon">
|
||||||
<CategoryIcon size={14} />
|
<CategoryIcon size={14} />
|
||||||
</div>
|
</div>
|
||||||
<h2>{group.category}</h2>
|
<h2>{categoryName(group.category, lang)}</h2>
|
||||||
<span class="category-count">{group.items.filter(i => !i.checked).length}</span>
|
<span class="category-count">{group.items.filter(i => !i.checked).length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -459,7 +452,7 @@
|
|||||||
{#if checkedCount > 0}
|
{#if checkedCount > 0}
|
||||||
<button class="btn-clear-checked" onclick={() => sync.clearChecked()}>
|
<button class="btn-clear-checked" onclick={() => sync.clearChecked()}>
|
||||||
<ListX size={16} />
|
<ListX size={16} />
|
||||||
Erledigte entfernen ({checkedCount})
|
{t('clear_checked', lang)} ({checkedCount})
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -476,7 +469,7 @@
|
|||||||
<h3>{parseQuantity(editingItem.name).name}</h3>
|
<h3>{parseQuantity(editingItem.name).name}</h3>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="edit-label">Kategorie</label>
|
<label class="edit-label">{t('kategorie', lang)}</label>
|
||||||
<div class="category-picker">
|
<div class="category-picker">
|
||||||
{#each SHOPPING_CATEGORIES as cat}
|
{#each SHOPPING_CATEGORIES as cat}
|
||||||
{@const meta = categoryMeta[cat] || categoryMeta['Sonstiges']}
|
{@const meta = categoryMeta[cat] || categoryMeta['Sonstiges']}
|
||||||
@@ -488,22 +481,22 @@
|
|||||||
onclick={() => { editCategory = cat; }}
|
onclick={() => { editCategory = cat; }}
|
||||||
>
|
>
|
||||||
<CatIcon size={14} />
|
<CatIcon size={14} />
|
||||||
<span>{cat}</span>
|
<span>{categoryName(cat, lang)}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class="edit-label">Icon</label>
|
<label class="edit-label">{t('icon', lang)}</label>
|
||||||
<div class="icon-search">
|
<div class="icon-search">
|
||||||
<Search size={14} />
|
<Search size={14} />
|
||||||
<input bind:value={iconSearch} type="text" placeholder="Icon suchen..." />
|
<input bind:value={iconSearch} type="text" placeholder={t('search_icon', lang)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="icon-picker">
|
<div class="icon-picker">
|
||||||
{#each filteredIconGroups as [cat, icons]}
|
{#each filteredIconGroups as [cat, icons]}
|
||||||
{@const meta = categoryMeta[cat] || categoryMeta['Sonstiges']}
|
{@const meta = categoryMeta[cat] || categoryMeta['Sonstiges']}
|
||||||
<div class="icon-group">
|
<div class="icon-group">
|
||||||
<span class="icon-group-label" style="color: {meta.color}">{cat}</span>
|
<span class="icon-group-label" style="color: {meta.color}">{categoryName(cat, lang)}</span>
|
||||||
<div class="icon-group-grid">
|
<div class="icon-group-grid">
|
||||||
{#each icons as [name, file]}
|
{#each icons as [name, file]}
|
||||||
<button
|
<button
|
||||||
@@ -521,9 +514,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="edit-actions">
|
<div class="edit-actions">
|
||||||
<button class="btn-cancel" onclick={closeEdit}>Abbrechen</button>
|
<button class="btn-cancel" onclick={closeEdit}>{t('cancel', lang)}</button>
|
||||||
<button class="btn-save" onclick={saveEdit} disabled={editSaving}>
|
<button class="btn-save" onclick={saveEdit} disabled={editSaving}>
|
||||||
{editSaving ? 'Speichern...' : 'Speichern'}
|
{editSaving ? t('saving', lang) : t('save', lang)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -538,27 +531,27 @@
|
|||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="edit-modal share-modal" onclick={(e) => e.stopPropagation()}>
|
<div class="edit-modal share-modal" onclick={(e) => e.stopPropagation()}>
|
||||||
<div class="share-header">
|
<div class="share-header">
|
||||||
<h3>Geteilte Links</h3>
|
<h3>{t('shared_links', lang)}</h3>
|
||||||
<button class="close-button" onclick={() => { showShareModal = false; }}>
|
<button class="close-button" onclick={() => { showShareModal = false; }}>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="share-desc">Jeder mit einem aktiven Link kann die Einkaufsliste bearbeiten.</p>
|
<p class="share-desc">{t('share_desc', lang)}</p>
|
||||||
|
|
||||||
{#if shareLoading}
|
{#if shareLoading}
|
||||||
<p class="share-loading">Laden...</p>
|
<p class="share-loading">{t('loading', lang)}</p>
|
||||||
{:else if shareTokens.length === 0}
|
{:else if shareTokens.length === 0}
|
||||||
<p class="share-empty">Keine aktiven Links.</p>
|
<p class="share-empty">{t('no_active_links', lang)}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="token-list">
|
<div class="token-list">
|
||||||
{#each shareTokens as t (t.id)}
|
{#each shareTokens as tok (tok.id)}
|
||||||
<div class="token-item">
|
<div class="token-item">
|
||||||
<div class="token-info">
|
<div class="token-info">
|
||||||
<span class="token-created-by">{t.createdBy}</span>
|
<span class="token-created-by">{tok.createdBy}</span>
|
||||||
<div class="token-expiry-row">
|
<div class="token-expiry-row">
|
||||||
<span class="token-ttl">noch {formatTTL(t.expiresAt)}</span>
|
<span class="token-ttl">{formatTTL(tok.expiresAt)}</span>
|
||||||
<select class="token-ttl-select" onchange={(e) => onTTLChange(t.id, e)}>
|
<select class="token-ttl-select" onchange={(e) => onTTLChange(tok.id, e)}>
|
||||||
<option value="" disabled selected>Ändern</option>
|
<option value="" disabled selected>{t('change', lang)}</option>
|
||||||
{#each TTL_OPTIONS as opt}
|
{#each TTL_OPTIONS as opt}
|
||||||
<option value={opt.ms}>{opt.label}</option>
|
<option value={opt.ms}>{opt.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -566,10 +559,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-actions">
|
<div class="token-actions">
|
||||||
<button class="btn-token-copy" onclick={() => copyTokenLink(t)} title="Link kopieren">
|
<button class="btn-token-copy" onclick={() => copyTokenLink(tok)} title={t('copy_link', lang)}>
|
||||||
{#if copiedId === t.id}<Check size={14} />{:else}<Copy size={14} />{/if}
|
{#if copiedId === tok.id}<Check size={14} />{:else}<Copy size={14} />{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-token-delete" onclick={() => deleteToken(t.id)} title="Löschen">
|
<button class="btn-token-delete" onclick={() => deleteToken(tok.id)} title={t('delete_', lang)}>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -580,7 +573,7 @@
|
|||||||
|
|
||||||
<button class="btn-new-token" onclick={createNewToken}>
|
<button class="btn-new-token" onclick={createNewToken}>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
Neuen Link erstellen
|
{t('create_new_link', lang)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,7 +581,7 @@
|
|||||||
|
|
||||||
{#if showCopyToast}
|
{#if showCopyToast}
|
||||||
<div class="copy-toast" transition:slide={{ duration: 150 }}>
|
<div class="copy-toast" transition:slide={{ duration: 150 }}>
|
||||||
<Check size={14} /> Kopiert
|
<Check size={14} /> {t('copied', lang)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||||
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
|
import { getCategoryEmoji } from '$lib/utils/categories';
|
||||||
import { toast } from '$lib/js/toast.svelte';
|
import { toast } from '$lib/js/toast.svelte';
|
||||||
import { isSettlementPayment, getSettlementIcon, getSettlementReceiver } from '$lib/utils/settlements';
|
import { isSettlementPayment, getSettlementIcon, getSettlementReceiver } from '$lib/utils/settlements';
|
||||||
import AddButton from '$lib/components/AddButton.svelte';
|
import AddButton from '$lib/components/AddButton.svelte';
|
||||||
|
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
import { formatCurrency } from '$lib/utils/formatters';
|
import { formatCurrency } from '$lib/utils/formatters';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const root = $derived(cospendRoot(lang));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
// Use server-side data with progressive enhancement
|
// Use server-side data with progressive enhancement
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
@@ -78,7 +82,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deletePayment(/** @type {string} */ paymentId) {
|
async function deletePayment(/** @type {string} */ paymentId) {
|
||||||
if (!confirm('Are you sure you want to delete this payment?')) {
|
if (!confirm(t('delete_payment_confirm', lang))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,14 +104,14 @@
|
|||||||
|
|
||||||
function formatAmountWithCurrency(/** @type {any} */ payment) {
|
function formatAmountWithCurrency(/** @type {any} */ payment) {
|
||||||
if (payment.currency === 'CHF' || !payment.originalAmount) {
|
if (payment.currency === 'CHF' || !payment.originalAmount) {
|
||||||
return formatCurrency(payment.amount, 'CHF', 'de-CH');
|
return formatCurrency(payment.amount, 'CHF', loc);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${formatCurrency(payment.originalAmount, payment.currency, 'de-CH')} ≈ ${formatCurrency(payment.amount, 'CHF', 'de-CH')}`;
|
return `${formatCurrency(payment.originalAmount, payment.currency, loc)} ≈ ${formatCurrency(payment.amount, 'CHF', loc)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(/** @type {string} */ dateString) {
|
function formatDate(/** @type {string} */ dateString) {
|
||||||
return new Date(dateString).toLocaleDateString('de-CH');
|
return new Date(dateString).toLocaleDateString(loc);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserSplitAmount(/** @type {any} */ payment, /** @type {string} */ username) {
|
function getUserSplitAmount(/** @type {any} */ payment, /** @type {string} */ username) {
|
||||||
@@ -115,34 +119,24 @@
|
|||||||
return split ? split.amount : 0;
|
return split ? split.amount : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSplitDescription(/** @type {any} */ payment) {
|
function getSplitDescription(/** @type {any} */ p) {
|
||||||
if (!payment.splits || payment.splits.length === 0) return 'No splits';
|
return splitDescription(p, lang);
|
||||||
|
|
||||||
if (payment.splitMethod === 'equal') {
|
|
||||||
return `Split equally among ${payment.splits.length} people`;
|
|
||||||
} else if (payment.splitMethod === 'full') {
|
|
||||||
return `Paid in full by ${payment.paidBy}`;
|
|
||||||
} else if (payment.splitMethod === 'personal_equal') {
|
|
||||||
return `Personal amounts + equal split among ${payment.splits.length} people`;
|
|
||||||
} else {
|
|
||||||
return `Custom split among ${payment.splits.length} people`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>All Payments - Cospend</title>
|
<title>{t('all_payments_title', lang)} - {t('cospend', lang)}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="payments-list">
|
<main class="payments-list">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h1>All Payments</h1>
|
<h1>{t('all_payments_title', lang)}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading && payments.length === 0}
|
{#if loading && payments.length === 0}
|
||||||
<div class="loading">Loading payments...</div>
|
<div class="loading">{t('loading_payments', lang)}</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">Error: {error}</div>
|
||||||
{:else if payments.length === 0}
|
{:else if payments.length === 0}
|
||||||
@@ -151,9 +145,9 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h2>No payments yet</h2>
|
<h2>{t('no_payments_yet', lang)}</h2>
|
||||||
<p>Start by adding your first shared expense</p>
|
<p>{t('start_first_expense', lang)}</p>
|
||||||
<a href="/cospend/payments/add" class="btn btn-primary">Add Your First Payment</a>
|
<a href="/{root}/payments/add" class="btn btn-primary">{t('add_first_payment', lang)}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -161,11 +155,11 @@
|
|||||||
{#each payments as payment}
|
{#each payments as payment}
|
||||||
{#if isSettlementPayment(payment)}
|
{#if isSettlementPayment(payment)}
|
||||||
<!-- Settlement Card - Distinct Layout -->
|
<!-- Settlement Card - Distinct Layout -->
|
||||||
<a href="/cospend/payments/view/{payment._id}" class="payment-card settlement-card">
|
<a href="/{root}/payments/view/{payment._id}" class="payment-card settlement-card">
|
||||||
<div class="settlement-header">
|
<div class="settlement-header">
|
||||||
<div class="settlement-badge">
|
<div class="settlement-badge">
|
||||||
<span class="settlement-icon">💸</span>
|
<span class="settlement-icon">💸</span>
|
||||||
<span class="settlement-label">Settlement</span>
|
<span class="settlement-label">{t('settlement', lang)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="settlement-date">{formatDate(payment.date)}</span>
|
<span class="settlement-date">{formatDate(payment.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,7 +189,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Regular Payment Card -->
|
<!-- Regular Payment Card -->
|
||||||
<a href="/cospend/payments/view/{payment._id}" class="payment-card">
|
<a href="/{root}/payments/view/{payment._id}" class="payment-card">
|
||||||
<div class="payment-header">
|
<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} />
|
||||||
@@ -205,7 +199,7 @@
|
|||||||
<h3>{payment.title}</h3>
|
<h3>{payment.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="payment-meta">
|
<div class="payment-meta">
|
||||||
<span class="category-name">{getCategoryName(payment.category || 'groceries')}</span>
|
<span class="category-name">{paymentCategoryName(payment.category || 'groceries', lang)}</span>
|
||||||
<span class="date">{formatDate(payment.date)}</span>
|
<span class="date">{formatDate(payment.date)}</span>
|
||||||
<span class="amount">{formatAmountWithCurrency(payment)}</span>
|
<span class="amount">{formatAmountWithCurrency(payment)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,29 +216,29 @@
|
|||||||
|
|
||||||
<div class="payment-details">
|
<div class="payment-details">
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="label">Paid by:</span>
|
<span class="label">{t('paid_by_label', lang)}</span>
|
||||||
<span class="value">{payment.paidBy}</span>
|
<span class="value">{payment.paidBy}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="label">Split:</span>
|
<span class="label">{t('split_method_label', lang)}</span>
|
||||||
<span class="value">{getSplitDescription(payment)}</span>
|
<span class="value">{getSplitDescription(payment)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if payment.splits && payment.splits.length > 0}
|
{#if payment.splits && payment.splits.length > 0}
|
||||||
<div class="splits-summary">
|
<div class="splits-summary">
|
||||||
<h4>Split Details</h4>
|
<h4>{t('split_details', lang)}</h4>
|
||||||
<div class="splits-list">
|
<div class="splits-list">
|
||||||
{#each payment.splits as split}
|
{#each payment.splits as split}
|
||||||
<div class="split-item">
|
<div class="split-item">
|
||||||
<span class="split-user">{split.username}</span>
|
<span class="split-user">{split.username}</span>
|
||||||
<span class="split-amount" 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 {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
|
||||||
{:else if split.amount < 0}
|
{:else if split.amount < 0}
|
||||||
owed {formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
|
{t('owed', lang)} {formatCurrency(Math.abs(split.amount), 'CHF', loc)}
|
||||||
{:else}
|
{:else}
|
||||||
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,14 +256,14 @@
|
|||||||
{#if data.currentOffset > 0}
|
{#if data.currentOffset > 0}
|
||||||
<a href="?offset={Math.max(0, data.currentOffset - data.limit)}&limit={data.limit}"
|
<a href="?offset={Math.max(0, data.currentOffset - data.limit)}&limit={data.limit}"
|
||||||
class="btn btn-secondary">
|
class="btn btn-secondary">
|
||||||
← Previous
|
{t('previous', lang)}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasMore}
|
{#if hasMore}
|
||||||
<a href="?offset={data.currentOffset + data.limit}&limit={data.limit}"
|
<a href="?offset={data.currentOffset + data.limit}&limit={data.limit}"
|
||||||
class="btn btn-secondary">
|
class="btn btn-secondary">
|
||||||
Next →
|
{t('next', lang)}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -277,14 +271,14 @@
|
|||||||
{#if hasMore}
|
{#if hasMore}
|
||||||
<button class="btn btn-secondary js-only" onclick={loadMore} disabled={loading}
|
<button class="btn btn-secondary js-only" onclick={loadMore} disabled={loading}
|
||||||
style="display: none;">
|
style="display: none;">
|
||||||
{loading ? 'Loading...' : 'Load More (JS)'}
|
{loading ? t('loading_ellipsis', lang) : t('load_more', lang)}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AddButton href="/cospend/payments/add" />
|
<AddButton href="/{root}/payments/add" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.payments-list {
|
.payments-list {
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { getCategoryOptions } from '$lib/utils/categories';
|
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n, frequencyDescription } from '$lib/js/cospendI18n';
|
||||||
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
|
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
|
||||||
import { validateCronExpression, getFrequencyDescription, calculateNextExecutionDate } from '$lib/utils/recurring';
|
import { validateCronExpression, calculateNextExecutionDate } from '$lib/utils/recurring';
|
||||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||||
import SplitMethodSelector from '$lib/components/cospend/SplitMethodSelector.svelte';
|
import SplitMethodSelector from '$lib/components/cospend/SplitMethodSelector.svelte';
|
||||||
import UsersList from '$lib/components/cospend/UsersList.svelte';
|
import UsersList from '$lib/components/cospend/UsersList.svelte';
|
||||||
@@ -14,6 +15,10 @@
|
|||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const root = $derived(cospendRoot(lang));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
// Initialize form data with server values if available (for error handling)
|
// Initialize form data with server values if available (for error handling)
|
||||||
/** @type {Record<string, any>} */
|
/** @type {Record<string, any>} */
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
@@ -82,36 +87,36 @@
|
|||||||
personalAmounts[user] = 0;
|
personalAmounts[user] = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
let categoryOptions = $derived(getCategoryOptions());
|
let categoryOptions = $derived(getCategoryOptionsI18n(lang));
|
||||||
|
|
||||||
// Reactive text for "Paid in Full" option
|
// Reactive text for "Paid in Full" option
|
||||||
let paidInFullText = $derived.by(() => {
|
let paidInFullText = $derived.by(() => {
|
||||||
// No-JS fallback text - always generic
|
// No-JS fallback text - always generic
|
||||||
if (!jsEnhanced) {
|
if (!jsEnhanced) {
|
||||||
if (predefinedMode) {
|
if (predefinedMode) {
|
||||||
return users.length === 2 ? 'Paid in Full for other' : 'Paid in Full for others';
|
return t('paid_in_full', lang);
|
||||||
} else {
|
} else {
|
||||||
return 'Paid in Full for others';
|
return t('paid_in_full', lang);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// JavaScript-enhanced reactive text
|
// JavaScript-enhanced reactive text
|
||||||
if (!formData.paidBy) {
|
if (!formData.paidBy) {
|
||||||
return 'Paid in Full';
|
return t('paid_in_full', lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for 2-user predefined setup
|
// Special handling for 2-user predefined setup
|
||||||
if (predefinedMode && users.length === 2) {
|
if (predefinedMode && users.length === 2) {
|
||||||
const otherUser = users.find(user => user !== formData.paidBy);
|
const otherUser = users.find(user => user !== formData.paidBy);
|
||||||
// Always show "for" the other user (who benefits) regardless of who pays
|
// Always show "for" the other user (who benefits) regardless of who pays
|
||||||
return otherUser ? `Paid in Full for ${otherUser}` : 'Paid in Full';
|
return otherUser ? `${t('paid_in_full_for', lang)} ${otherUser}` : t('paid_in_full', lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
// General case with JS
|
// General case with JS
|
||||||
if (formData.paidBy === data.currentUser) {
|
if (formData.paidBy === data.currentUser) {
|
||||||
return 'Paid in Full by You';
|
return t('paid_in_full_by_you', lang);
|
||||||
} else {
|
} else {
|
||||||
return `Paid in Full by ${formData.paidBy}`;
|
return `${t('paid_in_full_by', lang)} ${formData.paidBy}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,7 +297,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
await goto('/cospend/dash');
|
await goto(`/${root}/dash`);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
@@ -318,7 +323,7 @@
|
|||||||
startDate: new Date(recurringData.startDate)
|
startDate: new Date(recurringData.startDate)
|
||||||
});
|
});
|
||||||
const nextDate = calculateNextExecutionDate(recurringPayment, new Date(recurringData.startDate));
|
const nextDate = calculateNextExecutionDate(recurringPayment, new Date(recurringData.startDate));
|
||||||
nextExecutionPreview = nextDate.toLocaleString('de-CH', {
|
nextExecutionPreview = nextDate.toLocaleString(loc, {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
@@ -357,44 +362,44 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Add Payment - Cospend</title>
|
<title>{t('add_payment_title', lang)} - {t('cospend', lang)}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="add-payment">
|
<main class="add-payment">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Add New Payment</h1>
|
<h1>{t('add_payment_title', lang)}</h1>
|
||||||
<p>Create a new shared expense or recurring payment</p>
|
<p>{t('add_payment_subtitle', lang)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" use:enhance class="payment-form">
|
<form method="POST" use:enhance class="payment-form">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Payment Details</h2>
|
<h2>{t('payment_details_section', lang)}</h2>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">Title *</label>
|
<label for="title">{t('title_label', lang)}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
required
|
required
|
||||||
placeholder="e.g., Dinner at restaurant"
|
placeholder={t('title_placeholder', lang)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description">Description</label>
|
<label for="description">{t('description_label', lang)}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
placeholder="Additional details..."
|
placeholder={t('description_placeholder', lang)}
|
||||||
rows="3"
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="category">Category *</label>
|
<label for="category">{t('category_star', lang)}</label>
|
||||||
<select id="category" name="category" value={formData.category} required>
|
<select id="category" name="category" value={formData.category} required>
|
||||||
{#each categoryOptions as option}
|
{#each categoryOptions as option}
|
||||||
<option value={option.value}>{option.label}</option>
|
<option value={option.value}>{option.label}</option>
|
||||||
@@ -404,7 +409,7 @@
|
|||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="amount">Amount *</label>
|
<label for="amount">{t('amount_label', lang)}</label>
|
||||||
<div class="amount-currency">
|
<div class="amount-currency">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -424,11 +429,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if formData.currency !== 'CHF'}
|
{#if formData.currency !== 'CHF'}
|
||||||
<div class="conversion-info">
|
<div class="conversion-info">
|
||||||
<small class="help-text">Amount will be converted to CHF using exchange rates for the payment date</small>
|
<small class="help-text">{t('conversion_hint', lang)}</small>
|
||||||
|
|
||||||
{#if loadingExchangeRate}
|
{#if loadingExchangeRate}
|
||||||
<div class="conversion-preview loading">
|
<div class="conversion-preview loading">
|
||||||
<small>🔄 Fetching exchange rate...</small>
|
<small>🔄 {t('fetching_rate', lang)}</small>
|
||||||
</div>
|
</div>
|
||||||
{:else if exchangeRateError}
|
{:else if exchangeRateError}
|
||||||
<div class="conversion-preview error">
|
<div class="conversion-preview error">
|
||||||
@@ -448,7 +453,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="date">Payment Date</label>
|
<label for="date">{t('payment_date', lang)}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="date"
|
id="date"
|
||||||
@@ -457,13 +462,13 @@
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{#if formData.currency !== 'CHF'}
|
{#if formData.currency !== 'CHF'}
|
||||||
<small class="help-text">Exchange rate will be fetched for this date</small>
|
<small class="help-text">{t('exchange_rate_date', lang)}</small>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="paidBy">Paid by</label>
|
<label for="paidBy">{t('paid_by_form', lang)}</label>
|
||||||
<select id="paidBy" name="paidBy" bind:value={formData.paidBy} required>
|
<select id="paidBy" name="paidBy" bind:value={formData.paidBy} required>
|
||||||
{#each users as user}
|
{#each users as user}
|
||||||
<option value={user}>{user}</option>
|
<option value={user}>{user}</option>
|
||||||
@@ -474,7 +479,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<Toggle bind:checked={formData.isRecurring} />
|
<Toggle bind:checked={formData.isRecurring} />
|
||||||
<span>Make this a recurring payment</span>
|
<span>{t('make_recurring', lang)}</span>
|
||||||
<input type="hidden" name="isRecurring" value={formData.isRecurring ? 'true' : 'false'} />
|
<input type="hidden" name="isRecurring" value={formData.isRecurring ? 'true' : 'false'} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,24 +487,24 @@
|
|||||||
|
|
||||||
{#if formData.isRecurring}
|
{#if formData.isRecurring}
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Recurring Payment</h2>
|
<h2>{t('recurring_section', lang)}</h2>
|
||||||
|
|
||||||
<div class="recurring-options">
|
<div class="recurring-options">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="frequency">Frequency *</label>
|
<label for="frequency">{t('frequency_label', lang)}</label>
|
||||||
<select id="frequency" name="recurringFrequency" bind:value={recurringData.frequency} required>
|
<select id="frequency" name="recurringFrequency" bind:value={recurringData.frequency} required>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">{t('freq_daily', lang)}</option>
|
||||||
<option value="weekly">Weekly</option>
|
<option value="weekly">{t('freq_weekly', lang)}</option>
|
||||||
<option value="monthly">Monthly</option>
|
<option value="monthly">{t('freq_monthly', lang)}</option>
|
||||||
<option value="quarterly">Quarterly</option>
|
<option value="quarterly">{t('freq_quarterly', lang)}</option>
|
||||||
<option value="yearly">Yearly</option>
|
<option value="yearly">{t('freq_yearly', lang)}</option>
|
||||||
<option value="custom">Custom (Cron)</option>
|
<option value="custom">{t('freq_custom', lang)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="recurringStartDate">Start Date *</label>
|
<label for="recurringStartDate">{t('start_date', lang)}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="recurringStartDate"
|
id="recurringStartDate"
|
||||||
@@ -539,7 +544,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="recurringEndDate">End Date (optional)</label>
|
<label for="recurringEndDate">{t('end_date_optional', lang)}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="recurringEndDate"
|
id="recurringEndDate"
|
||||||
@@ -547,15 +552,15 @@
|
|||||||
bind:value={recurringData.endDate}
|
bind:value={recurringData.endDate}
|
||||||
min={recurringData.startDate}
|
min={recurringData.startDate}
|
||||||
/>
|
/>
|
||||||
<small class="help-text">Leave empty for indefinite recurring</small>
|
<small class="help-text">{t('end_date_hint', lang)}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{#if nextExecutionPreview}
|
{#if nextExecutionPreview}
|
||||||
<div class="execution-preview">
|
<div class="execution-preview">
|
||||||
<h3>Next Execution</h3>
|
<h3>{t('next_execution_preview', lang)}</h3>
|
||||||
<p class="next-execution">{nextExecutionPreview}</p>
|
<p class="next-execution">{nextExecutionPreview}</p>
|
||||||
<p class="frequency-description">{getFrequencyDescription(/** @type {any} */ (recurringData))}</p>
|
<p class="frequency-description">{frequencyDescription(/** @type {any} */ (recurringData), lang)}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -566,6 +571,7 @@
|
|||||||
bind:imagePreview={imagePreview}
|
bind:imagePreview={imagePreview}
|
||||||
bind:imageFile={imageFile}
|
bind:imageFile={imageFile}
|
||||||
bind:uploading={uploading}
|
bind:uploading={uploading}
|
||||||
|
{lang}
|
||||||
onimageSelected={(file) => { imageFile = file; }}
|
onimageSelected={(file) => { imageFile = file; }}
|
||||||
onimageRemoved={handleImageRemoved}
|
onimageRemoved={handleImageRemoved}
|
||||||
onerror={(message) => { error = message; }}
|
onerror={(message) => { error = message; }}
|
||||||
@@ -577,6 +583,7 @@
|
|||||||
currentUser={data.session?.user?.nickname || data.currentUser}
|
currentUser={data.session?.user?.nickname || data.currentUser}
|
||||||
predefinedMode={predefinedMode}
|
predefinedMode={predefinedMode}
|
||||||
canRemoveUsers={!predefinedMode}
|
canRemoveUsers={!predefinedMode}
|
||||||
|
{lang}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Server-side fallback: simple text inputs for users -->
|
<!-- Server-side fallback: simple text inputs for users -->
|
||||||
@@ -616,7 +623,7 @@
|
|||||||
<div class="error">{error}</div>
|
<div class="error">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<SaveFab disabled={loading} label="Create payment" />
|
<SaveFab disabled={loading} label={t('create_payment', lang)} />
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getCategoryOptions } from '$lib/utils/categories';
|
import { page } from '$app/stores';
|
||||||
|
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n } from '$lib/js/cospendI18n';
|
||||||
import FormSection from '$lib/components/FormSection.svelte';
|
import FormSection from '$lib/components/FormSection.svelte';
|
||||||
import ImageUpload from '$lib/components/ImageUpload.svelte';
|
import ImageUpload from '$lib/components/ImageUpload.svelte';
|
||||||
import SaveFab from '$lib/components/SaveFab.svelte';
|
import SaveFab from '$lib/components/SaveFab.svelte';
|
||||||
@@ -12,6 +13,10 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const root = $derived(cospendRoot(lang));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
/** @type {PaymentWithSplits | null} */
|
/** @type {PaymentWithSplits | null} */
|
||||||
let payment = $state(null);
|
let payment = $state(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -37,7 +42,7 @@
|
|||||||
/** @type {number | null} */
|
/** @type {number | null} */
|
||||||
let originalAmount = $state(null);
|
let originalAmount = $state(null);
|
||||||
|
|
||||||
let categoryOptions = $derived(getCategoryOptions());
|
let categoryOptions = $derived(getCategoryOptionsI18n(lang));
|
||||||
|
|
||||||
// Recalculate splits when amount changes
|
// Recalculate splits when amount changes
|
||||||
function recalculateSplits() {
|
function recalculateSplits() {
|
||||||
@@ -244,7 +249,7 @@
|
|||||||
throw new Error('Failed to update payment');
|
throw new Error('Failed to update payment');
|
||||||
}
|
}
|
||||||
|
|
||||||
await goto('/cospend/payments');
|
await goto(`/${root}/payments`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -274,7 +279,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to payments list after successful deletion
|
// Redirect to payments list after successful deletion
|
||||||
goto('/cospend/payments');
|
goto(`/${root}/payments`);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
@@ -352,25 +357,25 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Edit Payment - Cospend</title>
|
<title>{t('edit_payment_title', lang)} - {t('cospend', lang)}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="edit-payment">
|
<main class="edit-payment">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Edit Payment</h1>
|
<h1>{t('edit_payment_title', lang)}</h1>
|
||||||
<p>Modify payment details and receipt image</p>
|
<p>{t('edit_payment_subtitle', lang)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading payment...</div>
|
<div class="loading">{t('loading_payments', lang)}</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">Error: {error}</div>
|
||||||
{:else if payment}
|
{:else if payment}
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }
|
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }
|
||||||
} class="payment-form">
|
} class="payment-form">
|
||||||
<FormSection title="Payment Details">
|
<FormSection title={t('payment_details', lang)}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">Title *</label>
|
<label for="title">{t('title_label', lang)}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
@@ -380,7 +385,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description">Description</label>
|
<label for="description">{t('description_label', lang)}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
bind:value={payment.description}
|
bind:value={payment.description}
|
||||||
@@ -389,7 +394,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="category">Category</label>
|
<label for="category">{t('category_star', lang)}</label>
|
||||||
<select id="category" bind:value={payment.category} required>
|
<select id="category" bind:value={payment.category} required>
|
||||||
{#each categoryOptions as option}
|
{#each categoryOptions as option}
|
||||||
<option value={option.value}>{option.label}</option>
|
<option value={option.value}>{option.label}</option>
|
||||||
@@ -399,7 +404,7 @@
|
|||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="amount">Amount *</label>
|
<label for="amount">{t('amount_label', lang)}</label>
|
||||||
<div class="amount-currency">
|
<div class="amount-currency">
|
||||||
{#if payment.originalAmount && payment.currency !== 'CHF'}
|
{#if payment.originalAmount && payment.currency !== 'CHF'}
|
||||||
<!-- Show original amount for foreign currency -->
|
<!-- Show original amount for foreign currency -->
|
||||||
@@ -462,7 +467,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="date">Date</label>
|
<label for="date">{t('date', lang)}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="date"
|
id="date"
|
||||||
@@ -474,7 +479,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="paidBy">Paid by</label>
|
<label for="paidBy">{t('paid_by_form', lang)}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="paidBy"
|
id="paidBy"
|
||||||
@@ -489,6 +494,7 @@
|
|||||||
bind:imageFile={imageFile}
|
bind:imageFile={imageFile}
|
||||||
bind:uploading={uploading}
|
bind:uploading={uploading}
|
||||||
currentImage={payment.image}
|
currentImage={payment.image}
|
||||||
|
{lang}
|
||||||
onimageSelected={handleImageUpload}
|
onimageSelected={handleImageUpload}
|
||||||
onimageRemoved={handleImageRemoved}
|
onimageRemoved={handleImageRemoved}
|
||||||
oncurrentImageRemoved={handleCurrentImageRemoved}
|
oncurrentImageRemoved={handleCurrentImageRemoved}
|
||||||
@@ -496,18 +502,18 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if payment.splits && payment.splits.length > 0}
|
{#if payment.splits && payment.splits.length > 0}
|
||||||
<FormSection title="Split Configuration">
|
<FormSection title={t('split_config', lang)}>
|
||||||
<div class="split-method-info">
|
<div class="split-method-info">
|
||||||
<span class="label">Split Method:</span>
|
<span class="label">{t('split_method_form', lang)}</span>
|
||||||
<span class="value">
|
<span class="value">
|
||||||
{#if payment.splitMethod === 'equal'}
|
{#if payment.splitMethod === 'equal'}
|
||||||
Equal Split
|
{t('equal_split', lang)}
|
||||||
{:else if payment.splitMethod === 'full'}
|
{:else if payment.splitMethod === 'full'}
|
||||||
Paid in Full
|
{t('paid_in_full', lang)}
|
||||||
{:else if payment.splitMethod === 'personal_equal'}
|
{:else if payment.splitMethod === 'personal_equal'}
|
||||||
Personal + Equal Split
|
{t('personal_equal_split', lang)}
|
||||||
{:else if payment.splitMethod === 'proportional'}
|
{:else if payment.splitMethod === 'proportional'}
|
||||||
Custom Proportions
|
{t('custom_proportions', lang)}
|
||||||
{:else}
|
{:else}
|
||||||
{payment.splitMethod}
|
{payment.splitMethod}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -516,8 +522,8 @@
|
|||||||
|
|
||||||
{#if payment.splitMethod === 'personal_equal'}
|
{#if payment.splitMethod === 'personal_equal'}
|
||||||
<div class="personal-amounts-editor">
|
<div class="personal-amounts-editor">
|
||||||
<h3>Personal Amounts</h3>
|
<h3>{t('personal_amounts', lang)}</h3>
|
||||||
<p class="description">Enter personal amounts for each user. The remainder will be split equally.</p>
|
<p class="description">{t('personal_amounts_desc', lang)}</p>
|
||||||
{#each payment.splits as split, index}
|
{#each payment.splits as split, index}
|
||||||
<div class="personal-input">
|
<div class="personal-input">
|
||||||
<label for="personal_{split.username}">{split.username}</label>
|
<label for="personal_{split.username}">{split.username}</label>
|
||||||
@@ -540,10 +546,10 @@
|
|||||||
{@const remainder = Math.max(0, Number(payment.amount) - totalPersonal)}
|
{@const remainder = Math.max(0, Number(payment.amount) - totalPersonal)}
|
||||||
{@const hasError = totalPersonal > Number(payment.amount)}
|
{@const hasError = totalPersonal > Number(payment.amount)}
|
||||||
<div class="remainder-info" class:error={hasError}>
|
<div class="remainder-info" class:error={hasError}>
|
||||||
<span>Total Personal: CHF {totalPersonal.toFixed(2)}</span>
|
<span>{t('total_personal', lang)}: CHF {totalPersonal.toFixed(2)}</span>
|
||||||
<span>Remainder to Split: CHF {remainder.toFixed(2)}</span>
|
<span>{t('remainder_to_split', lang)}: CHF {remainder.toFixed(2)}</span>
|
||||||
{#if hasError}
|
{#if hasError}
|
||||||
<div class="error-message">⚠️ Personal amounts exceed total payment amount!</div>
|
<div class="error-message">⚠️ {t('personal_exceeds', lang)}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -551,17 +557,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="splits-display">
|
<div class="splits-display">
|
||||||
<h3>Split Preview</h3>
|
<h3>{t('split_preview', lang)}</h3>
|
||||||
{#each payment.splits as split}
|
{#each payment.splits as split}
|
||||||
<div class="split-item">
|
<div class="split-item">
|
||||||
<span class="split-username">{split.username}</span>
|
<span class="split-username">{split.username}</span>
|
||||||
<span class="split-amount" 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)}
|
{t('owes', lang)} CHF {split.amount.toFixed(2)}
|
||||||
{:else if split.amount < 0}
|
{:else if split.amount < 0}
|
||||||
owed CHF {Math.abs(split.amount).toFixed(2)}
|
{t('owed', lang)} CHF {Math.abs(split.amount).toFixed(2)}
|
||||||
{:else}
|
{:else}
|
||||||
owes CHF {split.amount.toFixed(2)}
|
{t('owes', lang)} CHF {split.amount.toFixed(2)}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -581,11 +587,11 @@
|
|||||||
onclick={deletePayment}
|
onclick={deletePayment}
|
||||||
disabled={deleting || saving}
|
disabled={deleting || saving}
|
||||||
>
|
>
|
||||||
{deleting ? 'Deleting...' : 'Delete Payment'}
|
{deleting ? t('deleting', lang) : t('delete_payment', lang)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SaveFab disabled={saving || deleting} label="Save changes" />
|
<SaveFab disabled={saving || deleting} label={t('save_changes', lang)} />
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto, invalidateAll } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||||
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
|
import { getCategoryEmoji } from '$lib/utils/categories';
|
||||||
import EditButton from '$lib/components/EditButton.svelte';
|
import EditButton from '$lib/components/EditButton.svelte';
|
||||||
|
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
|
|
||||||
import { formatCurrency } from '$lib/utils/formatters';
|
import { formatCurrency } from '$lib/utils/formatters';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const root = $derived(cospendRoot(lang));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
// Use server-side data with progressive enhancement
|
// Use server-side data with progressive enhancement
|
||||||
/** @type {any | null} */
|
/** @type {any | null} */
|
||||||
let payment = $derived(data.payment || null);
|
let payment = $derived(data.payment || null);
|
||||||
@@ -30,40 +36,30 @@
|
|||||||
|
|
||||||
function formatAmountWithCurrency(/** @type {any} */ payment) {
|
function formatAmountWithCurrency(/** @type {any} */ payment) {
|
||||||
if (payment.currency === 'CHF' || !payment.originalAmount) {
|
if (payment.currency === 'CHF' || !payment.originalAmount) {
|
||||||
return formatCurrency(payment.amount, 'CHF', 'de-CH');
|
return formatCurrency(payment.amount, 'CHF', loc);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${formatCurrency(payment.originalAmount, payment.currency, 'de-CH')} ≈ ${formatCurrency(payment.amount, 'CHF', 'de-CH')}`;
|
return `${formatCurrency(payment.originalAmount, payment.currency, loc)} ≈ ${formatCurrency(payment.amount, 'CHF', loc)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(/** @type {string} */ dateString) {
|
function formatDate(/** @type {string} */ dateString) {
|
||||||
return new Date(dateString).toLocaleDateString('de-CH');
|
return new Date(dateString).toLocaleDateString(loc);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSplitDescription(/** @type {any} */ payment) {
|
function getSplitDescription(/** @type {any} */ payment) {
|
||||||
if (!payment.splits || payment.splits.length === 0) return 'No splits';
|
return splitDescription(payment, lang);
|
||||||
|
|
||||||
if (payment.splitMethod === 'equal') {
|
|
||||||
return `Split equally among ${payment.splits.length} people`;
|
|
||||||
} else if (payment.splitMethod === 'full') {
|
|
||||||
return `Paid in full by ${payment.paidBy}`;
|
|
||||||
} else if (payment.splitMethod === 'personal_equal') {
|
|
||||||
return `Personal amounts + equal split among ${payment.splits.length} people`;
|
|
||||||
} else {
|
|
||||||
return `Custom split among ${payment.splits.length} people`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{payment ? payment.title : 'Payment'} - Cospend</title>
|
<title>{payment ? payment.title : 'Payment'} - {t('cospend', lang)}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="payment-view">
|
<main class="payment-view">
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading payment...</div>
|
<div class="loading">{t('loading_payments', lang)}</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">Error: {error}</div>
|
||||||
{:else if payment}
|
{:else if payment}
|
||||||
@@ -78,7 +74,7 @@
|
|||||||
{formatAmountWithCurrency(payment)}
|
{formatAmountWithCurrency(payment)}
|
||||||
{#if payment.currency !== 'CHF' && payment.exchangeRate}
|
{#if payment.currency !== 'CHF' && payment.exchangeRate}
|
||||||
<div class="exchange-rate-info">
|
<div class="exchange-rate-info">
|
||||||
<small>Exchange rate: 1 {payment.currency} = {payment.exchangeRate.toFixed(4)} CHF</small>
|
<small>{t('exchange_rate', lang)}: 1 {payment.currency} = {payment.exchangeRate.toFixed(4)} CHF</small>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -93,30 +89,30 @@
|
|||||||
<div class="payment-info">
|
<div class="payment-info">
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Date:</span>
|
<span class="label">{t('date', lang)}</span>
|
||||||
<span class="value">{formatDate(payment.date)}</span>
|
<span class="value">{formatDate(payment.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Paid by:</span>
|
<span class="label">{t('paid_by_label', lang)}</span>
|
||||||
<span class="value">{payment.paidBy}</span>
|
<span class="value">{payment.paidBy}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Created by:</span>
|
<span class="label">{t('created_by', lang)}</span>
|
||||||
<span class="value">{payment.createdBy}</span>
|
<span class="value">{payment.createdBy}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Category:</span>
|
<span class="label">{t('category_label', lang)}</span>
|
||||||
<span class="value">{getCategoryName(payment.category || 'groceries')}</span>
|
<span class="value">{paymentCategoryName(payment.category || 'groceries', lang)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Split method:</span>
|
<span class="label">{t('split_method_label', lang)}</span>
|
||||||
<span class="value">{getSplitDescription(payment)}</span>
|
<span class="value">{getSplitDescription(payment)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if payment.description}
|
{#if payment.description}
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<h3>Description</h3>
|
<h3>{t('description', lang)}</h3>
|
||||||
<p>{payment.description}</p>
|
<p>{payment.description}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -124,7 +120,7 @@
|
|||||||
|
|
||||||
{#if payment.splits && payment.splits.length > 0}
|
{#if payment.splits && payment.splits.length > 0}
|
||||||
<div class="splits-section">
|
<div class="splits-section">
|
||||||
<h3>Split Details</h3>
|
<h3>{t('split_details', lang)}</h3>
|
||||||
<div class="splits-list">
|
<div class="splits-list">
|
||||||
{#each payment.splits as split}
|
{#each payment.splits as split}
|
||||||
<div class="split-item" class:current-user={split.username === data.session?.user?.nickname}>
|
<div class="split-item" class:current-user={split.username === data.session?.user?.nickname}>
|
||||||
@@ -133,17 +129,17 @@
|
|||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="username">{split.username}</span>
|
<span class="username">{split.username}</span>
|
||||||
{#if split.username === data.session?.user?.nickname}
|
{#if split.username === data.session?.user?.nickname}
|
||||||
<span class="you-badge">You</span>
|
<span class="you-badge">{t('you', lang)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
|
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
|
||||||
{#if split.amount > 0}
|
{#if split.amount > 0}
|
||||||
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
|
||||||
{:else if split.amount < 0}
|
{:else if split.amount < 0}
|
||||||
owed {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
{t('owed', lang)} {formatCurrency(split.amount, 'CHF', loc)}
|
||||||
{:else}
|
{:else}
|
||||||
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +152,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{#if payment}
|
{#if payment}
|
||||||
<EditButton href="/cospend/payments/edit/{data.paymentId}" />
|
<EditButton href="/{root}/payments/edit/{data.paymentId}" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
|
import { getCategoryEmoji } from '$lib/utils/categories';
|
||||||
import { getFrequencyDescription, formatNextExecution } from '$lib/utils/recurring';
|
|
||||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||||
import { toast } from '$lib/js/toast.svelte';
|
import { toast } from '$lib/js/toast.svelte';
|
||||||
import AddButton from '$lib/components/AddButton.svelte';
|
import AddButton from '$lib/components/AddButton.svelte';
|
||||||
import { formatCurrency } from '$lib/utils/formatters';
|
import { formatCurrency } from '$lib/utils/formatters';
|
||||||
import Toggle from '$lib/components/Toggle.svelte';
|
import Toggle from '$lib/components/Toggle.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { detectCospendLang, cospendRoot, t, locale, paymentCategoryName, frequencyDescription, formatNextExecutionI18n } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const root = $derived(cospendRoot(lang));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
/** @type {any[]} */
|
/** @type {any[]} */
|
||||||
let recurringPayments = $state([]);
|
let recurringPayments = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -60,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRecurringPayment(/** @type {string} */ paymentId, /** @type {string} */ title) {
|
async function deleteRecurringPayment(/** @type {string} */ paymentId, /** @type {string} */ title) {
|
||||||
if (!confirm(`Are you sure you want to delete the recurring payment "${title}"?`)) {
|
if (!confirm(`${t('delete_recurring_confirm', lang)} "${title}"?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +86,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(/** @type {string} */ dateString) {
|
function formatDate(/** @type {string} */ dateString) {
|
||||||
return new Date(dateString).toLocaleDateString('de-CH');
|
return new Date(dateString).toLocaleDateString(loc);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -92,31 +97,31 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Recurring Payments - Cospend</title>
|
<title>{t('recurring_title', lang)} - {t('cospend', lang)}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="recurring-payments">
|
<main class="recurring-payments">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Recurring Payments</h1>
|
<h1>{t('recurring_title', lang)}</h1>
|
||||||
<p>Automate your regular shared expenses</p>
|
<p>{t('recurring_subtitle', lang)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<label>
|
<label>
|
||||||
<Toggle bind:checked={showActiveOnly} />
|
<Toggle bind:checked={showActiveOnly} />
|
||||||
<span>Show active only</span>
|
<span>{t('show_active_only', lang)}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading recurring payments...</div>
|
<div class="loading">{t('loading_recurring', lang)}</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">Error: {error}</div>
|
||||||
{:else if recurringPayments.length === 0}
|
{:else if recurringPayments.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<h2>No recurring payments found</h2>
|
<h2>{t('no_recurring', lang)}</h2>
|
||||||
<p>Create your first recurring payment to automate regular expenses like rent, utilities, or subscriptions.</p>
|
<p>{t('no_recurring_desc', lang)}</p>
|
||||||
<a href="/cospend/payments/add" class="btn btn-primary">Add Your First Payment</a>
|
<a href="/{root}/payments/add" class="btn btn-primary">{t('add_first_payment', lang)}</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="payments-grid">
|
<div class="payments-grid">
|
||||||
@@ -127,11 +132,11 @@
|
|||||||
<span class="category-emoji">{getCategoryEmoji(payment.category)}</span>
|
<span class="category-emoji">{getCategoryEmoji(payment.category)}</span>
|
||||||
<h3>{payment.title}</h3>
|
<h3>{payment.title}</h3>
|
||||||
<span class="status-badge" class:active={payment.isActive} class:inactive={!payment.isActive}>
|
<span class="status-badge" class:active={payment.isActive} class:inactive={!payment.isActive}>
|
||||||
{payment.isActive ? 'Active' : 'Inactive'}
|
{payment.isActive ? t('active', lang) : t('inactive', lang)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="payment-amount">
|
<div class="payment-amount">
|
||||||
{formatCurrency(payment.amount, 'CHF', 'de-CH')}
|
{formatCurrency(payment.amount, 'CHF', loc)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,17 +146,17 @@
|
|||||||
|
|
||||||
<div class="payment-details">
|
<div class="payment-details">
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="label">Category:</span>
|
<span class="label">{t('category_label', lang)}</span>
|
||||||
<span class="value">{getCategoryName(payment.category)}</span>
|
<span class="value">{paymentCategoryName(payment.category, lang)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="label">Frequency:</span>
|
<span class="label">{t('frequency', lang)}</span>
|
||||||
<span class="value">{getFrequencyDescription(payment)}</span>
|
<span class="value">{frequencyDescription(payment, lang)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="label">Paid by:</span>
|
<span class="label">{t('paid_by_label', lang)}</span>
|
||||||
<div class="payer-info">
|
<div class="payer-info">
|
||||||
<ProfilePicture username={payment.paidBy} size={20} />
|
<ProfilePicture username={payment.paidBy} size={20} />
|
||||||
<span class="value">{payment.paidBy}</span>
|
<span class="value">{payment.paidBy}</span>
|
||||||
@@ -159,29 +164,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="label">Next execution:</span>
|
<span class="label">{t('next_execution', lang)}</span>
|
||||||
<span class="value next-execution">
|
<span class="value next-execution">
|
||||||
{formatNextExecution(new Date(payment.nextExecutionDate))}
|
{formatNextExecutionI18n(new Date(payment.nextExecutionDate), lang)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if payment.lastExecutionDate}
|
{#if payment.lastExecutionDate}
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="label">Last executed:</span>
|
<span class="label">{t('last_executed', lang)}</span>
|
||||||
<span class="value">{formatDate(payment.lastExecutionDate)}</span>
|
<span class="value">{formatDate(payment.lastExecutionDate)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if payment.endDate}
|
{#if payment.endDate}
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="label">Ends:</span>
|
<span class="label">{t('ends', lang)}</span>
|
||||||
<span class="value">{formatDate(payment.endDate)}</span>
|
<span class="value">{formatDate(payment.endDate)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="splits-preview">
|
<div class="splits-preview">
|
||||||
<h4>Split between:</h4>
|
<h4>{t('split_between', lang)}</h4>
|
||||||
<div class="splits-list">
|
<div class="splits-list">
|
||||||
{#each payment.splits as split}
|
{#each payment.splits as split}
|
||||||
<div class="split-item">
|
<div class="split-item">
|
||||||
@@ -189,11 +194,11 @@
|
|||||||
<span class="username">{split.username}</span>
|
<span class="username">{split.username}</span>
|
||||||
<span class="split-amount" 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 {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
|
||||||
{:else if split.amount < 0}
|
{:else if split.amount < 0}
|
||||||
gets {formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
|
{t('gets', lang)} {formatCurrency(Math.abs(split.amount), 'CHF', loc)}
|
||||||
{:else}
|
{:else}
|
||||||
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
|
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,8 +207,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<a href="/cospend/recurring/edit/{payment._id}" class="btn btn-secondary btn-small">
|
<a href="/{root}/recurring/edit/{payment._id}" class="btn btn-secondary btn-small">
|
||||||
Edit
|
{t('edit', lang)}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
class="btn btn-small"
|
class="btn btn-small"
|
||||||
@@ -211,13 +216,13 @@
|
|||||||
class:btn-success={!payment.isActive}
|
class:btn-success={!payment.isActive}
|
||||||
onclick={() => toggleActiveStatus(payment._id, payment.isActive)}
|
onclick={() => toggleActiveStatus(payment._id, payment.isActive)}
|
||||||
>
|
>
|
||||||
{payment.isActive ? 'Pause' : 'Activate'}
|
{payment.isActive ? t('pause', lang) : t('activate', lang)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger btn-small"
|
class="btn btn-danger btn-small"
|
||||||
onclick={() => deleteRecurringPayment(payment._id, payment.title)}
|
onclick={() => deleteRecurringPayment(payment._id, payment.title)}
|
||||||
>
|
>
|
||||||
Delete
|
{t('delete_', lang)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,7 +231,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AddButton href="/cospend/payments/add" />
|
<AddButton href="/{root}/payments/add" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.recurring-payments {
|
.recurring-payments {
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getCategoryOptions } from '$lib/utils/categories';
|
import { page } from '$app/stores';
|
||||||
|
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n, frequencyDescription } from '$lib/js/cospendI18n';
|
||||||
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
|
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
|
||||||
import { validateCronExpression, getFrequencyDescription, calculateNextExecutionDate } from '$lib/utils/recurring';
|
import { validateCronExpression, calculateNextExecutionDate } from '$lib/utils/recurring';
|
||||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||||
import SplitMethodSelector from '$lib/components/cospend/SplitMethodSelector.svelte';
|
import SplitMethodSelector from '$lib/components/cospend/SplitMethodSelector.svelte';
|
||||||
import UsersList from '$lib/components/cospend/UsersList.svelte';
|
import UsersList from '$lib/components/cospend/UsersList.svelte';
|
||||||
@@ -11,6 +12,10 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const root = $derived(cospendRoot(lang));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let formData = $state({
|
let formData = $state({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -55,7 +60,7 @@
|
|||||||
let exchangeRateTimeout = $state();
|
let exchangeRateTimeout = $state();
|
||||||
let jsEnhanced = $state(false);
|
let jsEnhanced = $state(false);
|
||||||
|
|
||||||
let categoryOptions = $derived(getCategoryOptions());
|
let categoryOptions = $derived(getCategoryOptionsI18n(lang));
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
jsEnhanced = true;
|
jsEnhanced = true;
|
||||||
@@ -134,7 +139,7 @@
|
|||||||
startDate: new Date(formData.startDate)
|
startDate: new Date(formData.startDate)
|
||||||
});
|
});
|
||||||
const nextDate = calculateNextExecutionDate(recurringPayment, new Date(formData.startDate));
|
const nextDate = calculateNextExecutionDate(recurringPayment, new Date(formData.startDate));
|
||||||
nextExecutionPreview = nextDate.toLocaleString('de-CH', {
|
nextExecutionPreview = nextDate.toLocaleString(loc, {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
@@ -199,7 +204,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
await goto('/cospend/recurring');
|
await goto(`/${root}/recurring`);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
@@ -284,47 +289,47 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Edit Recurring Payment - Cospend</title>
|
<title>{t('edit_recurring_title', lang)} - {t('cospend', lang)}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="edit-recurring-payment">
|
<main class="edit-recurring-payment">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Edit Recurring Payment</h1>
|
<h1>{t('edit_recurring_title', lang)}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loadingPayment}
|
{#if loadingPayment}
|
||||||
<div class="loading">Loading recurring payment...</div>
|
<div class="loading">{t('loading_recurring', lang)}</div>
|
||||||
{:else if error && !formData.title}
|
{:else if error && !formData.title}
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">Error: {error}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }
|
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }
|
||||||
} class="payment-form">
|
} class="payment-form">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Payment Details</h2>
|
<h2>{t('payment_details_section', lang)}</h2>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">Title *</label>
|
<label for="title">{t('title_label', lang)}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
bind:value={formData.title}
|
bind:value={formData.title}
|
||||||
required
|
required
|
||||||
placeholder="e.g., Monthly rent, Weekly groceries"
|
placeholder={t('title_placeholder', lang)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description">Description</label>
|
<label for="description">{t('description_label', lang)}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
bind:value={formData.description}
|
bind:value={formData.description}
|
||||||
placeholder="Additional details about this recurring payment..."
|
placeholder={t('description_placeholder', lang)}
|
||||||
rows="3"
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="category">Category *</label>
|
<label for="category">{t('category_star', lang)}</label>
|
||||||
<select id="category" bind:value={formData.category} required>
|
<select id="category" bind:value={formData.category} required>
|
||||||
{#each categoryOptions as option}
|
{#each categoryOptions as option}
|
||||||
<option value={option.value}>{option.label}</option>
|
<option value={option.value}>{option.label}</option>
|
||||||
@@ -334,7 +339,7 @@
|
|||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="amount">Amount *</label>
|
<label for="amount">{t('amount_label', lang)}</label>
|
||||||
<div class="amount-currency">
|
<div class="amount-currency">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -377,7 +382,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="paidBy">Paid by</label>
|
<label for="paidBy">{t('paid_by_form', lang)}</label>
|
||||||
<select id="paidBy" bind:value={formData.paidBy} required>
|
<select id="paidBy" bind:value={formData.paidBy} required>
|
||||||
{#each users as user}
|
{#each users as user}
|
||||||
<option value={user}>{user}</option>
|
<option value={user}>{user}</option>
|
||||||
@@ -387,30 +392,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="isActive">Status</label>
|
<label for="isActive">{t('status_label', lang)}</label>
|
||||||
<select id="isActive" bind:value={formData.isActive}>
|
<select id="isActive" bind:value={formData.isActive}>
|
||||||
<option value={true}>Active</option>
|
<option value={true}>{t('active', lang)}</option>
|
||||||
<option value={false}>Inactive</option>
|
<option value={false}>{t('inactive', lang)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Recurring Schedule</h2>
|
<h2>{t('recurring_schedule', lang)}</h2>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="frequency">Frequency *</label>
|
<label for="frequency">{t('frequency_label', lang)}</label>
|
||||||
<select id="frequency" bind:value={formData.frequency} required>
|
<select id="frequency" bind:value={formData.frequency} required>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">{t('freq_daily', lang)}</option>
|
||||||
<option value="weekly">Weekly</option>
|
<option value="weekly">{t('freq_weekly', lang)}</option>
|
||||||
<option value="monthly">Monthly</option>
|
<option value="monthly">{t('freq_monthly', lang)}</option>
|
||||||
<option value="custom">Custom (Cron)</option>
|
<option value="custom">{t('freq_custom', lang)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="startDate">Start Date *</label>
|
<label for="startDate">{t('start_date', lang)}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="startDate"
|
id="startDate"
|
||||||
@@ -448,20 +453,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="endDate">End Date (optional)</label>
|
<label for="endDate">{t('end_date_optional', lang)}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="endDate"
|
id="endDate"
|
||||||
bind:value={formData.endDate}
|
bind:value={formData.endDate}
|
||||||
/>
|
/>
|
||||||
<div class="help-text">Leave blank for indefinite recurring payments</div>
|
<div class="help-text">{t('end_date_hint', lang)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if nextExecutionPreview}
|
{#if nextExecutionPreview}
|
||||||
<div class="execution-preview">
|
<div class="execution-preview">
|
||||||
<h3>Next Execution</h3>
|
<h3>{t('next_execution_preview', lang)}</h3>
|
||||||
<p class="next-execution">{nextExecutionPreview}</p>
|
<p class="next-execution">{nextExecutionPreview}</p>
|
||||||
<p class="frequency-description">{getFrequencyDescription(/** @type {any} */ (formData))}</p>
|
<p class="frequency-description">{frequencyDescription(/** @type {any} */ (formData), lang)}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -472,6 +477,7 @@
|
|||||||
currentUser={data.session?.user?.nickname}
|
currentUser={data.session?.user?.nickname}
|
||||||
{predefinedMode}
|
{predefinedMode}
|
||||||
canRemoveUsers={!predefinedMode}
|
canRemoveUsers={!predefinedMode}
|
||||||
|
{lang}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SplitMethodSelector
|
<SplitMethodSelector
|
||||||
@@ -490,7 +496,7 @@
|
|||||||
<div class="error">{error}</div>
|
<div class="error">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<SaveFab disabled={loading || cronError} label="Save changes" />
|
<SaveFab disabled={loading || cronError} label={t('save_changes', lang)} />
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { detectCospendLang, cospendRoot } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals, request }) => {
|
export const load: PageServerLoad = async ({ fetch, locals, request, url }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -43,7 +44,7 @@ export const load: PageServerLoad = async ({ fetch, locals, request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
settle: async ({ request, fetch, locals }) => {
|
settle: async ({ request, fetch, locals, url }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
|
|
||||||
const settlementType = data.get('settlementType');
|
const settlementType = data.get('settlementType');
|
||||||
@@ -113,7 +114,8 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect back to dashboard on success
|
// Redirect back to dashboard on success
|
||||||
throw redirect(303, '/cospend');
|
const root = cospendRoot(detectCospendLang(url.pathname));
|
||||||
|
throw redirect(303, `/${root}`);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error && typeof error === 'object' && 'status' in error && (error as { status: number }).status === 303) {
|
if (error && typeof error === 'object' && 'status' in error && (error as { status: number }).status === 303) {
|
||||||
throw error; // Re-throw redirect
|
throw error; // Re-throw redirect
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||||
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
|
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
|
||||||
|
import { detectCospendLang, cospendRoot, t, locale } from '$lib/js/cospendI18n';
|
||||||
|
|
||||||
import { formatCurrency } from '$lib/utils/formatters';
|
import { formatCurrency } from '$lib/utils/formatters';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const lang = $derived(detectCospendLang($page.url.pathname));
|
||||||
|
const root = $derived(cospendRoot(lang));
|
||||||
|
const loc = $derived(locale(lang));
|
||||||
|
|
||||||
// Use server-side data with progressive enhancement
|
// Use server-side data with progressive enhancement
|
||||||
let debtData = $derived(data.debtData || {
|
let debtData = $derived(data.debtData || {
|
||||||
whoOwesMe: [],
|
whoOwesMe: [],
|
||||||
@@ -38,7 +43,7 @@
|
|||||||
from: debtData.whoOwesMe[0].username,
|
from: debtData.whoOwesMe[0].username,
|
||||||
to: data.currentUser,
|
to: data.currentUser,
|
||||||
amount: debtData.whoOwesMe[0].netAmount,
|
amount: debtData.whoOwesMe[0].netAmount,
|
||||||
description: `Settlement: ${debtData.whoOwesMe[0].username} pays ${data.currentUser}`
|
description: `${t('settlement_payment', lang)}: ${debtData.whoOwesMe[0].username} → ${data.currentUser}`
|
||||||
};
|
};
|
||||||
if (!settlementAmount) {
|
if (!settlementAmount) {
|
||||||
settlementAmount = debtData.whoOwesMe[0].netAmount.toString();
|
settlementAmount = debtData.whoOwesMe[0].netAmount.toString();
|
||||||
@@ -49,7 +54,7 @@
|
|||||||
from: data.currentUser,
|
from: data.currentUser,
|
||||||
to: debtData.whoIOwe[0].username,
|
to: debtData.whoIOwe[0].username,
|
||||||
amount: debtData.whoIOwe[0].netAmount,
|
amount: debtData.whoIOwe[0].netAmount,
|
||||||
description: `Settlement: ${data.currentUser} pays ${debtData.whoIOwe[0].username}`
|
description: `${t('settlement_payment', lang)}: ${data.currentUser} → ${debtData.whoIOwe[0].username}`
|
||||||
};
|
};
|
||||||
if (!settlementAmount) {
|
if (!settlementAmount) {
|
||||||
settlementAmount = debtData.whoIOwe[0].netAmount.toString();
|
settlementAmount = debtData.whoIOwe[0].netAmount.toString();
|
||||||
@@ -67,7 +72,7 @@
|
|||||||
from: user,
|
from: user,
|
||||||
to: currentUser,
|
to: currentUser,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
description: `Settlement: ${user} pays ${currentUser}`
|
description: `${t('settlement_payment', lang)}: ${user} → ${currentUser}`
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
selectedSettlement = {
|
selectedSettlement = {
|
||||||
@@ -75,7 +80,7 @@
|
|||||||
from: currentUser,
|
from: currentUser,
|
||||||
to: user,
|
to: user,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
description: `Settlement: ${currentUser} pays ${user}`
|
description: `${t('settlement_payment', lang)}: ${currentUser} → ${user}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
settlementAmount = amount.toString();
|
settlementAmount = amount.toString();
|
||||||
@@ -83,13 +88,13 @@
|
|||||||
|
|
||||||
async function processSettlement() {
|
async function processSettlement() {
|
||||||
if (!selectedSettlement || !settlementAmount) {
|
if (!selectedSettlement || !settlementAmount) {
|
||||||
error = 'Please select a settlement and enter an amount';
|
error = t('error_select_settlement', lang);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const amount = parseFloat(/** @type {string} */ (settlementAmount));
|
const amount = parseFloat(/** @type {string} */ (settlementAmount));
|
||||||
if (isNaN(amount) || amount <= 0) {
|
if (isNaN(amount) || amount <= 0) {
|
||||||
error = 'Please enter a valid positive amount';
|
error = t('error_valid_amount', lang);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +137,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect back to dashboard on success
|
// Redirect back to dashboard on success
|
||||||
window.location.href = '/cospend';
|
window.location.href = `/${root}`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
submitting = false;
|
submitting = false;
|
||||||
@@ -142,36 +147,36 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Settle Debts - Cospend</title>
|
<title>{t('settle_title', lang)} - {t('cospend', lang)}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="settle-main">
|
<main class="settle-main">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h1>Settle Debts</h1>
|
<h1>{t('settle_title', lang)}</h1>
|
||||||
<p>Record payments to settle outstanding debts between users</p>
|
<p>{t('settle_subtitle', lang)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading debt information...</div>
|
<div class="loading">{t('loading_debts', lang)}</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">Error: {error}</div>
|
<div class="error">Error: {error}</div>
|
||||||
{:else if debtData.whoOwesMe.length === 0 && debtData.whoIOwe.length === 0}
|
{:else if debtData.whoOwesMe.length === 0 && debtData.whoIOwe.length === 0}
|
||||||
<div class="no-debts">
|
<div class="no-debts">
|
||||||
<h2>🎉 All Settled!</h2>
|
<h2>🎉 {t('all_settled', lang)}</h2>
|
||||||
<p>No outstanding debts to settle. Everyone is even!</p>
|
<p>{t('no_debts_msg', lang)}</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a href="/cospend/dash" class="btn btn-primary">Back to Dashboard</a>
|
<a href="/{root}/dash" class="btn btn-primary">{t('back_to_dashboard', lang)}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="settlement-container">
|
<div class="settlement-container">
|
||||||
<!-- Available Settlements -->
|
<!-- Available Settlements -->
|
||||||
<div class="available-settlements">
|
<div class="available-settlements">
|
||||||
<h2>Available Settlements</h2>
|
<h2>{t('available_settlements', lang)}</h2>
|
||||||
|
|
||||||
{#if debtData.whoOwesMe.length > 0}
|
{#if debtData.whoOwesMe.length > 0}
|
||||||
<div class="settlement-section">
|
<div class="settlement-section">
|
||||||
<h3>Money You're Owed</h3>
|
<h3>{t('money_owed_to_you', lang)}</h3>
|
||||||
{#each debtData.whoOwesMe as debt}
|
{#each debtData.whoOwesMe as debt}
|
||||||
<div class="settlement-option"
|
<div class="settlement-option"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -184,11 +189,11 @@
|
|||||||
<ProfilePicture username={debt.username} size={40} />
|
<ProfilePicture username={debt.username} size={40} />
|
||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
<span class="username">{debt.username}</span>
|
<span class="username">{debt.username}</span>
|
||||||
<span class="debt-amount">owes you {formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
|
<span class="debt-amount">{t('owes_you', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settlement-action">
|
<div class="settlement-action">
|
||||||
<span class="action-text">Receive Payment</span>
|
<span class="action-text">{t('receive_payment', lang)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -197,7 +202,7 @@
|
|||||||
|
|
||||||
{#if debtData.whoIOwe.length > 0}
|
{#if debtData.whoIOwe.length > 0}
|
||||||
<div class="settlement-section">
|
<div class="settlement-section">
|
||||||
<h3>Money You Owe</h3>
|
<h3>{t('money_you_owe', lang)}</h3>
|
||||||
{#each debtData.whoIOwe as debt}
|
{#each debtData.whoIOwe as debt}
|
||||||
<div class="settlement-option"
|
<div class="settlement-option"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -210,11 +215,11 @@
|
|||||||
<ProfilePicture username={debt.username} size={40} />
|
<ProfilePicture username={debt.username} size={40} />
|
||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
<span class="username">{debt.username}</span>
|
<span class="username">{debt.username}</span>
|
||||||
<span class="debt-amount">you owe {formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
|
<span class="debt-amount">{t('you_owe', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settlement-action">
|
<div class="settlement-action">
|
||||||
<span class="action-text">Make Payment</span>
|
<span class="action-text">{t('make_payment', lang)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -225,7 +230,7 @@
|
|||||||
<!-- Settlement Details -->
|
<!-- Settlement Details -->
|
||||||
{#if selectedSettlement}
|
{#if selectedSettlement}
|
||||||
<div class="settlement-details">
|
<div class="settlement-details">
|
||||||
<h2>Settlement Details</h2>
|
<h2>{t('settlement_details', lang)}</h2>
|
||||||
|
|
||||||
<div class="settlement-summary">
|
<div class="settlement-summary">
|
||||||
<div class="settlement-flow">
|
<div class="settlement-flow">
|
||||||
@@ -233,7 +238,7 @@
|
|||||||
<ProfilePicture username={selectedSettlement.from} size={48} />
|
<ProfilePicture username={selectedSettlement.from} size={48} />
|
||||||
<span class="username">{selectedSettlement.from}</span>
|
<span class="username">{selectedSettlement.from}</span>
|
||||||
{#if selectedSettlement.from === data.currentUser}
|
{#if selectedSettlement.from === data.currentUser}
|
||||||
<span class="you-badge">You</span>
|
<span class="you-badge">{t('you', lang)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-arrow">→</div>
|
<div class="flow-arrow">→</div>
|
||||||
@@ -241,13 +246,13 @@
|
|||||||
<ProfilePicture username={selectedSettlement.to} size={48} />
|
<ProfilePicture username={selectedSettlement.to} size={48} />
|
||||||
<span class="username">{selectedSettlement.to}</span>
|
<span class="username">{selectedSettlement.to}</span>
|
||||||
{#if selectedSettlement.to === data.currentUser}
|
{#if selectedSettlement.to === data.currentUser}
|
||||||
<span class="you-badge">You</span>
|
<span class="you-badge">{t('you', lang)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settlement-amount-section">
|
<div class="settlement-amount-section">
|
||||||
<label for="amount">Settlement Amount</label>
|
<label for="amount">{t('settlement_amount', lang)}</label>
|
||||||
<div class="amount-input">
|
<div class="amount-input">
|
||||||
<span class="currency">CHF</span>
|
<span class="currency">CHF</span>
|
||||||
<input
|
<input
|
||||||
@@ -274,60 +279,60 @@
|
|||||||
onclick={processSettlement}
|
onclick={processSettlement}
|
||||||
disabled={submitting || !settlementAmount}>
|
disabled={submitting || !settlementAmount}>
|
||||||
{#if submitting}
|
{#if submitting}
|
||||||
Recording Settlement...
|
{t('recording_settlement', lang)}
|
||||||
{:else}
|
{:else}
|
||||||
Record Settlement
|
{t('record_settlement', lang)}
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" onclick={() => selectedSettlement = null}>
|
<button class="btn btn-secondary" onclick={() => selectedSettlement = null}>
|
||||||
Cancel
|
{t('cancel', lang)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- No-JS Fallback Form -->
|
<!-- No-JS Fallback Form -->
|
||||||
<div class="settlement-details no-js-fallback">
|
<div class="settlement-details no-js-fallback">
|
||||||
<h2>Record Settlement</h2>
|
<h2>{t('record_settlement', lang)}</h2>
|
||||||
<form method="POST" action="?/settle" class="settlement-form">
|
<form method="POST" action="?/settle" class="settlement-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="settlementType">Settlement Type</label>
|
<label for="settlementType">{t('settlement_type', lang)}</label>
|
||||||
<select id="settlementType" name="settlementType" required>
|
<select id="settlementType" name="settlementType" required>
|
||||||
<option value="">Select settlement type</option>
|
<option value="">{t('select_settlement', lang)}</option>
|
||||||
{#each debtData.whoOwesMe as debt}
|
{#each debtData.whoOwesMe as debt}
|
||||||
<option value="receive" data-from="{debt.username}" data-to="{data.currentUser}">
|
<option value="receive" data-from="{debt.username}" data-to="{data.currentUser}">
|
||||||
Receive {formatCurrency(debt.netAmount, 'CHF', 'de-CH')} from {debt.username}
|
{t('receive_from', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)} {t('from', lang)} {debt.username}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
{#each debtData.whoIOwe as debt}
|
{#each debtData.whoIOwe as debt}
|
||||||
<option value="pay" data-from="{data.currentUser}" data-to="{debt.username}">
|
<option value="pay" data-from="{data.currentUser}" data-to="{debt.username}">
|
||||||
Pay {formatCurrency(debt.netAmount, 'CHF', 'de-CH')} to {debt.username}
|
{t('pay_to', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)} {t('to', lang)} {debt.username}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="fromUser">From User</label>
|
<label for="fromUser">{t('from_user', lang)}</label>
|
||||||
<select id="fromUser" name="fromUser" required>
|
<select id="fromUser" name="fromUser" required>
|
||||||
<option value="">Select payer</option>
|
<option value="">{t('select_payer', lang)}</option>
|
||||||
{#each [...debtData.whoOwesMe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user}
|
{#each [...debtData.whoOwesMe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user}
|
||||||
<option value="{user}">{user}{user === data.currentUser ? ' (You)' : ''}</option>
|
<option value="{user}">{user}{user === data.currentUser ? ` (${t('you', lang)})` : ''}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="toUser">To User</label>
|
<label for="toUser">{t('to_user', lang)}</label>
|
||||||
<select id="toUser" name="toUser" required>
|
<select id="toUser" name="toUser" required>
|
||||||
<option value="">Select recipient</option>
|
<option value="">{t('select_recipient', lang)}</option>
|
||||||
{#each [...debtData.whoIOwe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user}
|
{#each [...debtData.whoIOwe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user}
|
||||||
<option value="{user}">{user}{user === data.currentUser ? ' (You)' : ''}</option>
|
<option value="{user}">{user}{user === data.currentUser ? ` (${t('you', lang)})` : ''}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="fallback-amount">Settlement Amount (CHF)</label>
|
<label for="fallback-amount">{t('settlement_amount_chf', lang)}</label>
|
||||||
<input
|
<input
|
||||||
id="fallback-amount"
|
id="fallback-amount"
|
||||||
name="amount"
|
name="amount"
|
||||||
@@ -342,10 +347,10 @@
|
|||||||
|
|
||||||
<div class="settlement-actions">
|
<div class="settlement-actions">
|
||||||
<button type="submit" class="btn btn-settlement">
|
<button type="submit" class="btn btn-settlement">
|
||||||
Record Settlement
|
{t('record_settlement', lang)}
|
||||||
</button>
|
</button>
|
||||||
<a href="/cospend/dash" class="btn btn-secondary">
|
<a href="/{root}/dash" class="btn btn-secondary">
|
||||||
Cancel
|
{t('cancel', lang)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { LayoutServerLoad } from "./$types"
|
|
||||||
|
|
||||||
export const load : LayoutServerLoad = async ({locals}) => {
|
|
||||||
return {
|
|
||||||
session: locals.session ?? await locals.auth()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export function load() {
|
|
||||||
redirect(302, '/cospend/list');
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user