diff --git a/prisma/migrations/20230311162322_add_currency_rates/migration.sql b/prisma/migrations/20230311162322_add_currency_rates/migration.sql new file mode 100644 index 0000000..6d60f89 --- /dev/null +++ b/prisma/migrations/20230311162322_add_currency_rates/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "CurrencyRate" ( + "id" SERIAL NOT NULL, + "ownerId" INTEGER NOT NULL, + "cur1" TEXT NOT NULL, + "cur2" TEXT NOT NULL, + "rate" DOUBLE PRECISION NOT NULL, + + CONSTRAINT "CurrencyRate_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "CurrencyRate" ADD CONSTRAINT "CurrencyRate_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20230311173536_add_user_settings/migration.sql b/prisma/migrations/20230311173536_add_user_settings/migration.sql new file mode 100644 index 0000000..b238116 --- /dev/null +++ b/prisma/migrations/20230311173536_add_user_settings/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "UserSettings" ( + "userId" INTEGER NOT NULL, + "theme" TEXT, + "language" TEXT, + "currency" TEXT, + + CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("userId") +); + +-- AddForeignKey +ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a2d2cd..d0ff60d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,7 @@ model User { name String? password UserPassword? + settings UserSettings? tokens AuthToken[] groups UserToGroup[] @@ -36,6 +37,16 @@ model UserPassword { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model UserSettings { + userId Int @id + + theme String? + language String? + currency String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + model AuthToken { id Int @id @default(autoincrement()) value String @unique @@ -90,13 +101,14 @@ model Group { id Int @id @default(autoincrement()) name String - users UserToGroup[] - accounts Account[] - categories Category[] - transactions Transaction[] - tokens AuthToken[] - tags Tag[] - importRules ImportRule[] + users UserToGroup[] + accounts Account[] + categories Category[] + transactions Transaction[] + tokens AuthToken[] + tags Tag[] + importRules ImportRule[] + currencyRates CurrencyRate[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -176,3 +188,14 @@ model ImportRule { category Category? @relation(fields: [categoryId], references: [id]) tags Tag[] } + +model CurrencyRate { + id Int @id @default(autoincrement()) + ownerId Int + + cur1 String + cur2 String + rate Float + + owner Group @relation(fields: [ownerId], references: [id], onDelete: Cascade) +} diff --git a/src/lib/deps.ts b/src/lib/deps.ts index fe6f878..0276e56 100644 --- a/src/lib/deps.ts +++ b/src/lib/deps.ts @@ -7,4 +7,6 @@ export const deps = { transactions: 'transactions', importRules: 'importRules', tags: 'tags', + settings: 'settings', + currencyRates: 'currencyRates', }; diff --git a/src/lib/routes.ts b/src/lib/routes.ts index 62860c8..dd1d71c 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -24,6 +24,7 @@ type RouteKey = | 'transactions.import.rules.create' | 'settings' | 'settings.language' + | 'settings.currency_rates' | 'uikit'; export const routes: { [key in RouteKey]: Route } = { @@ -99,6 +100,10 @@ export const routes: { [key in RouteKey]: Route } = { path: '/settings/language', title: 'settings.select_language', }, + 'settings.currency_rates': { + path: '/settings/currency-rates', + title: 'currency_rates.title', + }, uikit: { path: '/uikit', title: 'settings.uikit', diff --git a/src/lib/translate/en.ts b/src/lib/translate/en.ts index c6bb273..24264ea 100644 --- a/src/lib/translate/en.ts +++ b/src/lib/translate/en.ts @@ -172,6 +172,16 @@ export const enDict: Dictionary = { // System 'system.category.transfer_in': 'Transfer from other account', 'system.category.transfer_out': 'Transfer to other account', + // Currency rates + 'currency_rates.title': 'Currency rates', + 'currency_rates.default_currency': 'Main currency', + 'currency_rates.new_currency_rate': 'New currency rate', + 'currency_rates.currency1': 'Currency 1', + 'currency_rates.currency2': 'Currency 2', + 'currency_rates.rate': 'Rate', + 'currency_rates.delete_currency_rate': 'Delete currency rate', + 'currency_rates.delete_currency_rate_success': 'Currency rate was deleted', + 'currency_rates.delete_currency_rate_failure': 'Failed to delete currency rate', // Settings 'settings.title': 'Settings', 'settings.language': 'Language', diff --git a/src/lib/translate/index.ts b/src/lib/translate/index.ts index ac51736..f27b54a 100644 --- a/src/lib/translate/index.ts +++ b/src/lib/translate/index.ts @@ -32,4 +32,4 @@ export const languages: { [key in Locales]: { name: string; icon: string } } = { export const activeLocale = storable((initialLocale as Locales) ?? 'ru-RU', 'locale'); activeLocale.subscribe((value) => locale.set(value)); -export const activeLocaleName = derived(activeLocale, (value) => languages[value].name); +export const activeLocaleName = derived(activeLocale, (value) => languages[value]?.name ?? languages['en-US'].name); diff --git a/src/lib/translate/messages.ts b/src/lib/translate/messages.ts index 0610fbb..03dd557 100644 --- a/src/lib/translate/messages.ts +++ b/src/lib/translate/messages.ts @@ -164,6 +164,16 @@ export type Messages = // System | 'system.category.transfer_out' | 'system.category.transfer_in' + // Currency rates + | 'currency_rates.title' + | 'currency_rates.default_currency' + | 'currency_rates.new_currency_rate' + | 'currency_rates.currency1' + | 'currency_rates.currency2' + | 'currency_rates.rate' + | 'currency_rates.delete_currency_rate' + | 'currency_rates.delete_currency_rate_success' + | 'currency_rates.delete_currency_rate_failure' // Settings | 'settings.title' | 'settings.common' diff --git a/src/lib/translate/ru.ts b/src/lib/translate/ru.ts index 8b17bcc..30148c5 100644 --- a/src/lib/translate/ru.ts +++ b/src/lib/translate/ru.ts @@ -172,6 +172,16 @@ export const ruDict: Dictionary = { // System 'system.category.transfer_in': 'Перевод c другого счёта', 'system.category.transfer_out': 'Перевод на другой счёт', + // Currency rates + 'currency_rates.title': 'Курсы валют', + 'currency_rates.default_currency': 'Основная валюта', + 'currency_rates.new_currency_rate': 'Новый курс валют', + 'currency_rates.currency1': 'Валюта 1', + 'currency_rates.currency2': 'Валюта 2', + 'currency_rates.rate': 'Курс', + 'currency_rates.delete_currency_rate': 'Удалить курс валют', + 'currency_rates.delete_currency_rate_success': 'Курс валют удалён', + 'currency_rates.delete_currency_rate_failure': 'Не удалось удалить курс валют', // Settings 'settings.title': 'Настройки', 'settings.language': 'Язык', diff --git a/src/lib/ui/Modal.svelte b/src/lib/ui/Modal.svelte index 094ec8a..ab4c450 100644 --- a/src/lib/ui/Modal.svelte +++ b/src/lib/ui/Modal.svelte @@ -4,6 +4,7 @@ export let opened: boolean; export let header: string | null = null; + export let width: string | number | null = null; const dispatch = createEventDispatcher(); const close = () => { @@ -38,6 +39,7 @@ on:click|stopPropagation in:fade={{ duration: 100 }} out:fade={{ duration: 100 }} + style:width={typeof width === 'string' ? width : `${width}rem`} aria-hidden > {#if $$slots.header} diff --git a/src/routes/accounts/+page.server.ts b/src/routes/accounts/+page.server.ts index e7c8307..dd4e8e8 100644 --- a/src/routes/accounts/+page.server.ts +++ b/src/routes/accounts/+page.server.ts @@ -7,7 +7,17 @@ import type { Category } from '@prisma/client'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals, depends }) => { - const { groupId } = checkUserAndGroup(locals, { redirect: true }); + const { userId, groupId } = checkUserAndGroup(locals, { redirect: true }); + + depends(deps.settings); + const settings = await db.userSettings.findUnique({ + where: { userId }, + }); + + depends(deps.currencyRates); + const currencyRates = await db.currencyRate.findMany({ + where: { ownerId: groupId }, + }); depends(deps.categories); const categories = await db.category.findMany({ @@ -31,6 +41,8 @@ export const load: PageServerLoad = async ({ locals, depends }) => { }); return { + settings, + currencyRates, accounts: accounts.map((account) => ({ ...account, sum: transactions diff --git a/src/routes/accounts/+page.svelte b/src/routes/accounts/+page.svelte index d522d17..5d9a802 100644 --- a/src/routes/accounts/+page.svelte +++ b/src/routes/accounts/+page.svelte @@ -22,6 +22,8 @@ useRightButton(AddAccountButton); export let data: PageData; + $: settings = data.settings; + $: currencyRates = data.currencyRates; $: accounts = data.accounts; $: categories = data.categories; $: tags = data.tags; @@ -71,6 +73,15 @@ return res; }, {} as { [key: string]: TransactionFullDto[] }); + const findCurrencyRate = (currency: string) => + settings?.currency !== currency + ? currencyRates.find( + ({ cur1, cur2 }) => [cur1, cur2].includes(settings?.currency) && [cur1, cur2].includes(currency), + ) ?? null + : null; + + $: currencyRate = findCurrencyRate(account?.currency ?? ''); + onMount(() => { if (cardId) { scrollToCard(cardId); @@ -116,7 +127,7 @@
{#each accounts as account} diff --git a/src/routes/accounts/AccountCard.svelte b/src/routes/accounts/AccountCard.svelte index 2da21df..aa68fdf 100644 --- a/src/routes/accounts/AccountCard.svelte +++ b/src/routes/accounts/AccountCard.svelte @@ -1,6 +1,6 @@
@@ -20,7 +24,16 @@
-
{formatMoney(account.sum, account.currency)}
+
+
+ {formatMoney(account.sum, account.currency)} +
+ {#if currencyRate} +
+ {formatMoney(account.sum * rate, otherCurrency)} +
+ {/if} +
@@ -32,6 +45,10 @@ .money-value { font-size: 1.2rem; } + .other-money-value { + font-size: 1rem; + color: var(--secondary-text-color); + } .footer { min-height: 2.5rem; } diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts index 7fbcd2b..759b8fb 100644 --- a/src/routes/settings/+page.server.ts +++ b/src/routes/settings/+page.server.ts @@ -2,6 +2,7 @@ import { deps } from '$lib/deps'; import { db } from '$lib/server'; import { getGroups } from '$lib/server/api/groups'; import { checkUserAndGroup } from '$lib/utils'; + import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals, depends }) => { diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 7749d8a..006bf34 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -30,6 +30,11 @@ + goto(routes['settings.currency_rates'].path)} + /> diff --git a/src/routes/settings/currency-rates/+page.server.ts b/src/routes/settings/currency-rates/+page.server.ts new file mode 100644 index 0000000..aa6cd9f --- /dev/null +++ b/src/routes/settings/currency-rates/+page.server.ts @@ -0,0 +1,113 @@ +import { ApiError } from '$lib/api'; +import { deps } from '$lib/deps'; +import { db, withActionMiddleware } from '$lib/server'; +import { checkNumberFormParameter, checkStringFormParameter } from '$lib/server/utils'; +import { checkUserAndGroup } from '$lib/utils'; +import type { Action, Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, depends }) => { + const { userId, groupId } = checkUserAndGroup(locals, { redirect: true }); + + depends(deps.settings); + const settings = await db.userSettings.findUnique({ where: { userId } }); + + depends(deps.currencyRates); + const items = await db.currencyRate.findMany({ where: { ownerId: groupId } }); + + return { settings, items }; +}; + +const setCurrency: Action = async ({ request, locals }) => { + const data = await request.formData(); + const currency = checkStringFormParameter(data, 'currency'); + + const { userId } = checkUserAndGroup(locals, { redirect: true }); + + const settings = await db.userSettings.findUnique({ where: { userId } }); + if (!settings) await db.userSettings.create({ data: { userId } }); + + const currencyRate = await db.userSettings.update({ + where: { userId }, + data: { currency }, + }); + + return { currencyRate }; +}; + +const create: Action = async ({ request, locals }) => { + const data = await request.formData(); + const cur1 = checkStringFormParameter(data, 'cur1'); + const cur2 = checkStringFormParameter(data, 'cur2'); + const rate = checkNumberFormParameter(data, 'rate'); + + const { groupId } = checkUserAndGroup(locals, { redirect: true }); + + const currencyRate = await db.currencyRate.create({ + data: { + cur1, + cur2, + rate, + owner: { connect: { id: groupId } }, + }, + }); + + return { currencyRate }; +}; + +const update: Action = async ({ request, locals }) => { + const data = await request.formData(); + const id = checkNumberFormParameter(data, 'id'); + const cur1 = checkStringFormParameter(data, 'cur1'); + const cur2 = checkStringFormParameter(data, 'cur2'); + const rate = checkNumberFormParameter(data, 'rate'); + + const { groupId } = checkUserAndGroup(locals, { redirect: true }); + + const item = await db.currencyRate.findUnique({ where: { id } }); + + if (!item) { + throw new ApiError(404, 'NOT_FOUND', `Currency rate #${id} not found`); + } + if (item?.ownerId !== groupId) { + throw new ApiError(403, 'FORBIDDEN', `You have no access to currency rate #${id}`); + } + + const currencyRate = await db.currencyRate.update({ + where: { id }, + data: { + cur1, + cur2, + rate, + owner: { connect: { id: groupId } }, + }, + }); + + return { currencyRate }; +}; + +const deleteAction: Action = async ({ request, locals }) => { + const data = await request.formData(); + const id = checkNumberFormParameter(data, 'id'); + + const { groupId } = checkUserAndGroup(locals, { redirect: true }); + + const item = await db.currencyRate.findUnique({ where: { id } }); + + if (!item) { + throw new ApiError(404, 'NOT_FOUND', `Currency rate #${id} not found`); + } + if (item?.ownerId !== groupId) { + throw new ApiError(403, 'FORBIDDEN', `You have no access to currency rate #${id}`); + } + + await db.currencyRate.delete({ + where: { id }, + }); +}; + +export const actions: Actions = { + setCurrency: withActionMiddleware(setCurrency), + create: withActionMiddleware(create), + update: withActionMiddleware(update), + delete: withActionMiddleware(deleteAction), +}; diff --git a/src/routes/settings/currency-rates/+page.svelte b/src/routes/settings/currency-rates/+page.svelte new file mode 100644 index 0000000..ddbadb0 --- /dev/null +++ b/src/routes/settings/currency-rates/+page.svelte @@ -0,0 +1,94 @@ + + +
+
+ ({ result }) => { + if (result.type == 'success') { + showSuccessToast($translate('common.save_changes_success')); + } + }} + > + +
+ +{#if opened} + +{/if} + + diff --git a/src/routes/settings/currency-rates/CurrencyRateModal.svelte b/src/routes/settings/currency-rates/CurrencyRateModal.svelte new file mode 100644 index 0000000..4c2a2cd --- /dev/null +++ b/src/routes/settings/currency-rates/CurrencyRateModal.svelte @@ -0,0 +1,77 @@ + + + +
+ + + + + + + {#if cur1 && cur2} +
1 {cur1} = {rate} {cur2}
+
1 {cur2} = {(1 / Number(rate)).toFixed(4)} {cur1}
+ {/if} + + {#if !!item} + + +
+ +
+ diff --git a/src/routes/transactions/TransactionListItem.svelte b/src/routes/transactions/TransactionListItem.svelte index 2001130..c1c6412 100644 --- a/src/routes/transactions/TransactionListItem.svelte +++ b/src/routes/transactions/TransactionListItem.svelte @@ -1,4 +1,6 @@ {/if} - - {incoming ? '+' : outgoing ? '-' : ''}{formatMoney(transaction.amount)} - {transaction.account.currency} - +
+ + {incoming ? '+' : outgoing ? '-' : ''}{formatMoney(transaction.amount)} + {transaction.account.currency} + + {#if currencyRate} + + {formatMoney(transaction.amount * rate, otherCurrency)} + + {/if} +
@@ -112,6 +125,11 @@ .amount { white-space: nowrap; } + .other-money-value { + opacity: 0.8; + font-size: 0.75rem; + color: var(--secondary-text-color); + } .incoming { color: var(--green-color); }