diff --git a/package-lock.json b/package-lock.json index 17f239100..51f339d80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -764,6 +764,7 @@ "version": "3.622.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.622.0.tgz", "integrity": "sha512-dwWDfN+S98npeY77Ugyv8VIHKRHN+n/70PWE4EgolcjaMrTINjvUh9a/SypFEs5JmBOAeCQt8S2QpM3Wvzp+pQ==", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -1424,6 +1425,7 @@ "version": "3.622.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.622.0.tgz", "integrity": "sha512-Yqtdf/wn3lcFVS42tR+zbz4HLyWxSmztjVW9L/yeMlvS7uza5nSkWqP/7ca+RxZnXLyrnA4jJtSHqykcErlhyg==", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -4007,6 +4009,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -9837,6 +9840,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.15.tgz", "integrity": "sha512-QbVlAkTI78wB4Mqgf2RDmgC0AOiJqer2c5k9STOOSXGv1S6ZkY37r/6UpE8DbQ2Du0ohsdoXgFNEyv+4eDoPEw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.59.13" }, @@ -9853,6 +9857,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -9958,6 +9963,7 @@ "https://trpc.io/sponsor" ], "license": "MIT", + "peer": true, "peerDependencies": { "@trpc/server": "11.0.0-rc.586+3388c9691" } @@ -9985,7 +9991,8 @@ "funding": [ "https://trpc.io/sponsor" ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tsconfig/node10": { "version": "1.0.11", @@ -10193,6 +10200,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -10291,6 +10299,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", "devOptional": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -10302,6 +10311,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.18.tgz", "integrity": "sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==", "devOptional": true, + "peer": true, "dependencies": { "@types/react": "*" } @@ -10393,6 +10403,7 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -10923,6 +10934,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11559,6 +11571,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001646", "electron-to-chromium": "^1.5.4", @@ -12575,7 +12588,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -12876,6 +12890,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -13100,6 +13115,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -13263,6 +13279,7 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -15836,6 +15853,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.20.0.tgz", "integrity": "sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==", "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -16380,6 +16398,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.0.7", "@swc/helpers": "0.5.15", @@ -16501,17 +16520,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next-intl/node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -17488,6 +17496,7 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", + "peer": true, "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -17721,6 +17730,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -17908,6 +17918,7 @@ "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -17977,6 +17988,7 @@ "integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.18.0", "@prisma/engines": "6.18.0" @@ -18092,6 +18104,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -18101,6 +18114,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18113,6 +18127,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", "integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -19171,6 +19186,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz", "integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -19306,6 +19322,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19374,6 +19391,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -19548,6 +19566,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/app/groups/[groupId]/expenses/amount-calculator.tsx b/src/app/groups/[groupId]/expenses/amount-calculator.tsx new file mode 100644 index 000000000..f06844cbe --- /dev/null +++ b/src/app/groups/[groupId]/expenses/amount-calculator.tsx @@ -0,0 +1,271 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { Delete } from 'lucide-react' +import { useState, useCallback, useEffect, useRef } from 'react' + +interface AmountCalculatorProps { + onApply: (value: string) => void + initialValue?: string +} + +type Operator = '+' | '-' | '×' | '÷' + +const isOperator = (char: string): char is Operator => + ['+', '-', '×', '÷'].includes(char) + +export function AmountCalculator({ onApply, initialValue }: AmountCalculatorProps) { + const [display, setDisplay] = useState(initialValue || '0') + const [hasResult, setHasResult] = useState(false) + const containerRef = useRef(null) + + const getLastChar = () => display.slice(-1) + + const appendToDisplay = useCallback((value: string) => { + setDisplay(prev => { + if (hasResult && !isOperator(value)) { + setHasResult(false) + return value === '.' ? '0.' : value + } + + if (prev === '0' && value !== '.' && !isOperator(value)) { + return value + } + + const lastChar = prev.slice(-1) + + // Prevent multiple operators in a row + if (isOperator(value) && isOperator(lastChar)) { + return prev.slice(0, -1) + value + } + + // Prevent multiple decimals in current number + if (value === '.') { + const parts = prev.split(/[+\-×÷]/) + const currentNumber = parts[parts.length - 1] + if (currentNumber.includes('.')) { + return prev + } + } + + setHasResult(false) + return prev + value + }) + }, [hasResult]) + + const clear = useCallback(() => { + setDisplay('0') + setHasResult(false) + }, []) + + const backspace = useCallback(() => { + setDisplay(prev => { + if (prev.length === 1 || hasResult) { + setHasResult(false) + return '0' + } + return prev.slice(0, -1) + }) + }, [hasResult]) + + const calculate = useCallback(() => { + try { + // Replace display operators with JS operators + const expression = display + .replace(/×/g, '*') + .replace(/÷/g, '/') + + // Remove trailing operator if present + const cleanExpression = expression.replace(/[+\-*/]$/, '') + + if (!cleanExpression) return + + // Safe evaluation using Function constructor + const result = new Function(`return ${cleanExpression}`)() + + if (typeof result === 'number' && isFinite(result)) { + // Round to 2 decimal places for currency + const rounded = Math.round(result * 100) / 100 + setDisplay(rounded.toString()) + setHasResult(true) + } + } catch { + // Invalid expression, do nothing + } + }, [display]) + + const handleApply = useCallback(() => { + // Calculate first if there's a pending operation + const lastChar = getLastChar() + if (isOperator(lastChar)) { + return + } + + // If not already calculated, calculate first + if (!hasResult && /[+\-×÷]/.test(display)) { + calculate() + } + + const value = display.replace(/^0+(?=\d)/, '') + onApply(value || '0') + }, [display, hasResult, calculate, onApply]) + + // Keyboard support + const handleKeyDown = useCallback((e: KeyboardEvent) => { + const key = e.key + + // Prevent default for calculator keys to avoid form submission etc. + if (/^[0-9+\-*/.=]$/.test(key) || ['Enter', 'Backspace', 'Escape', 'Delete'].includes(key)) { + e.preventDefault() + } + + // Numbers + if (/^[0-9]$/.test(key)) { + appendToDisplay(key) + return + } + + // Operators + switch (key) { + case '+': + appendToDisplay('+') + break + case '-': + appendToDisplay('-') + break + case '*': + appendToDisplay('×') + break + case '/': + appendToDisplay('÷') + break + case '.': + case ',': + appendToDisplay('.') + break + case 'Enter': + // If already calculated, apply the result; otherwise calculate + if (hasResult) { + handleApply() + } else { + calculate() + } + break + case '=': + calculate() + break + case 'Backspace': + backspace() + break + case 'Escape': + case 'Delete': + clear() + break + case 'c': + case 'C': + clear() + break + } + }, [appendToDisplay, calculate, backspace, clear, hasResult, handleApply]) + + // Auto-focus container and attach keyboard listener + useEffect(() => { + const container = containerRef.current + if (container) { + container.focus() + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleKeyDown]) + + const buttons: (string | { label: React.ReactNode; value: string; className?: string })[] = [ + { label: 'C', value: 'clear', className: 'text-destructive font-semibold' }, + { label: , value: 'backspace' }, + '÷', + '×', + '7', '8', '9', '-', + '4', '5', '6', '+', + '1', '2', '3', + { label: '=', value: 'equals', className: 'bg-primary text-primary-foreground hover:bg-primary/90 row-span-2' }, + { label: '0', value: '0', className: 'col-span-2' }, + '.', + ] + + const handleButtonClick = (btn: typeof buttons[number]) => { + const value = typeof btn === 'string' ? btn : btn.value + + switch (value) { + case 'clear': + clear() + break + case 'backspace': + backspace() + break + case 'equals': + calculate() + break + default: + appendToDisplay(value) + } + } + + return ( +
+ {/* Display */} +
+
+ {display} +
+
+ + {/* Button Grid */} +
+ {buttons.map((btn, index) => { + const isString = typeof btn === 'string' + const label = isString ? btn : btn.label + const value = isString ? btn : btn.value + const className = isString ? '' : btn.className + + const isOperatorBtn = typeof label === 'string' && isOperator(label) + + return ( + + ) + })} +
+ + {/* Apply Button */} + + + {/* Keyboard hint */} +

+ Use keyboard • Enter to {hasResult ? 'apply' : 'calculate'} +

+
+ ) +} + diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx index ebf2c883c..c05ff26db 100644 --- a/src/app/groups/[groupId]/expenses/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -54,7 +54,8 @@ import { import { AppRouterOutput } from '@/trpc/routers/_app' import { zodResolver } from '@hookform/resolvers/zod' import { RecurrenceRule } from '@prisma/client' -import { ChevronRight, Save } from 'lucide-react' +import { Calculator, ChevronRight, Save } from 'lucide-react' +import { AmountCalculator } from './amount-calculator' import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' import { useSearchParams } from 'next/navigation' @@ -64,6 +65,8 @@ import { match } from 'ts-pattern' import { DeletePopup } from '../../../../components/delete-popup' import { extractCategoryFromTitle } from '../../../../components/expense-form-actions' import { Textarea } from '../../../../components/ui/textarea' +import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' const enforceCurrencyPattern = (value: string) => value @@ -187,29 +190,29 @@ export function ExpenseForm({ resolver: zodResolver(expenseFormSchema), defaultValues: expense ? { - title: expense.title, - expenseDate: expense.expenseDate ?? new Date(), - amount: amountAsDecimal(expense.amount, groupCurrency), - originalCurrency: expense.originalCurrency ?? group.currencyCode, - originalAmount: expense.originalAmount ?? undefined, - conversionRate: expense.conversionRate?.toNumber(), - category: expense.categoryId, - paidBy: expense.paidById, - paidFor: expense.paidFor.map(({ participantId, shares }) => ({ - participant: participantId, - shares: (expense.splitMode === 'BY_AMOUNT' - ? amountAsDecimal(shares, groupCurrency) - : (shares / 100).toString()) as any, // Convert to string to ensure consistent handling - })), - splitMode: expense.splitMode, - saveDefaultSplittingOptions: false, - isReimbursement: expense.isReimbursement, - documents: expense.documents, - notes: expense.notes ?? '', - recurrenceRule: expense.recurrenceRule ?? undefined, - } + title: expense.title, + expenseDate: expense.expenseDate ?? new Date(), + amount: amountAsDecimal(expense.amount, groupCurrency), + originalCurrency: expense.originalCurrency ?? group.currencyCode, + originalAmount: expense.originalAmount ?? undefined, + conversionRate: expense.conversionRate?.toNumber(), + category: expense.categoryId, + paidBy: expense.paidById, + paidFor: expense.paidFor.map(({ participantId, shares }) => ({ + participant: participantId, + shares: (expense.splitMode === 'BY_AMOUNT' + ? amountAsDecimal(shares, groupCurrency) + : (shares / 100).toString()) as any, // Convert to string to ensure consistent handling + })), + splitMode: expense.splitMode, + saveDefaultSplittingOptions: false, + isReimbursement: expense.isReimbursement, + documents: expense.documents, + notes: expense.notes ?? '', + recurrenceRule: expense.recurrenceRule ?? undefined, + } : searchParams.get('reimbursement') - ? { + ? { title: t('reimbursement'), expenseDate: new Date(), amount: amountAsDecimal( @@ -224,9 +227,9 @@ export function ExpenseForm({ paidFor: [ searchParams.get('to') ? { - participant: searchParams.get('to')!, - shares: '1' as any, // String for consistent form handling - } + participant: searchParams.get('to')!, + shares: '1' as any, // String for consistent form handling + } : undefined, ], isReimbursement: true, @@ -236,7 +239,7 @@ export function ExpenseForm({ notes: '', recurrenceRule: RecurrenceRule.NONE, } - : { + : { title: searchParams.get('title') ?? '', expenseDate: searchParams.get('date') ? new Date(searchParams.get('date') as string) @@ -256,13 +259,13 @@ export function ExpenseForm({ saveDefaultSplittingOptions: false, documents: searchParams.get('imageUrl') ? [ - { - id: randomId(), - url: searchParams.get('imageUrl') as string, - width: Number(searchParams.get('imageWidth')), - height: Number(searchParams.get('imageHeight')), - }, - ] + { + id: randomId(), + url: searchParams.get('imageUrl') as string, + width: Number(searchParams.get('imageWidth')), + height: Number(searchParams.get('imageHeight')), + }, + ] : [], notes: '', recurrenceRule: RecurrenceRule.NONE, @@ -415,9 +418,8 @@ export function ExpenseForm({ let ratesDisplay = '' if (exchangeRate.data) { // non breaking spaces so the rate text is not split with line feeds - ratesDisplay = `${form.getValues('originalCurrency')}\xa01\xa0=\xa0${ - group.currencyCode - }\xa0${exchangeRate.data}` + ratesDisplay = `${form.getValues('originalCurrency')}\xa01\xa0=\xa0${group.currencyCode + }\xa0${exchangeRate.data}` } if (exchangeRate.error) { if (exchangeRate.error instanceof RangeError && exchangeRate.data) @@ -537,9 +539,8 @@ export function ExpenseForm({ />
( {t('conversionRateField.label')}
@@ -678,25 +678,46 @@ export function ExpenseForm({
{group.currency} - { - const v = enforceCurrencyPattern(event.target.value) - const income = Number(v) < 0 - setIsIncome(income) - if (income) form.setValue('isReimbursement', false) - onChange(v) - }} - onFocus={(e) => { - // we're adding a small delay to get around safaris issue with onMouseUp deselecting things again - const target = e.currentTarget - setTimeout(() => target.select(), 1) - }} - {...field} - /> + + { + const v = enforceCurrencyPattern(event.target.value) + const income = Number(v) < 0 + setIsIncome(income) + if (income) form.setValue('isReimbursement', false) + onChange(v) + }} + onFocus={(e) => { + // we're adding a small delay to get around safaris issue with onMouseUp deselecting things again + const target = e.currentTarget + setTimeout(() => target.select(), 1) + }} + {...field} + /> + + + + + + + { + const income = Number(value) < 0 + setIsIncome(income) + if (income) form.setValue('isReimbursement', false) + onChange(value) + }} + /> + + + +
@@ -823,11 +844,11 @@ export function ExpenseForm({ const newPaidFor = allSelected ? [] : group.participants.map((p) => ({ - participant: p.id, - shares: (paidFor.find( - (pfor) => pfor.participant === p.id, - )?.shares ?? '1') as any, // Use string to ensure consistent schema handling - })) + participant: p.id, + shares: (paidFor.find( + (pfor) => pfor.participant === p.id, + )?.shares ?? '1') as any, // Use string to ensure consistent schema handling + })) form.setValue('paidFor', newPaidFor as any, { shouldDirty: true, shouldTouch: true, @@ -836,7 +857,7 @@ export function ExpenseForm({ }} > {form.getValues().paidFor.length === - group.participants.length ? ( + group.participants.length ? ( <>{t('selectNone')} ) : ( <>{t('selectAll')} @@ -861,9 +882,8 @@ export function ExpenseForm({ render={({ field }) => { return (
@@ -880,23 +900,23 @@ export function ExpenseForm({ } checked ? form.setValue( - 'paidFor', - [ - ...field.value, - { - participant: id, - shares: '1', // Use string to ensure consistent schema handling - }, - ] as any, - options, - ) + 'paidFor', + [ + ...field.value, + { + participant: id, + shares: '1', // Use string to ensure consistent schema handling + }, + ] as any, + options, + ) : form.setValue( - 'paidFor', - field.value?.filter( - (value) => value.participant !== id, - ), - options, - ) + 'paidFor', + field.value?.filter( + (value) => value.participant !== id, + ), + options, + ) }} /> @@ -924,15 +944,15 @@ export function ExpenseForm({ }, shares: form.watch('splitMode') === - 'BY_PERCENTAGE' + 'BY_PERCENTAGE' ? Number(shares) * 100 // Convert percentage to basis points (e.g., 50% -> 5000) : form.watch('splitMode') === 'BY_AMOUNT' - ? amountAsMinorUnits( + ? amountAsMinorUnits( shares, groupCurrency, ) - : shares, + : shares, expenseId: '', participantId: '', }), @@ -1017,15 +1037,15 @@ export function ExpenseForm({ field.value.map((p) => p.participant === id ? { - participant: id, - originalAmount: - event.target - .value, - shares: - enforceCurrencyPattern( - convertedAmount, - ), - } + participant: id, + originalAmount: + event.target + .value, + shares: + enforceCurrencyPattern( + convertedAmount, + ), + } : p, ), ) @@ -1107,13 +1127,13 @@ export function ExpenseForm({ field.value.map((p) => p.participant === id ? { - participant: id, - shares: - enforceCurrencyPattern( - event.target - .value, - ), - } + participant: id, + shares: + enforceCurrencyPattern( + event.target + .value, + ), + } : p, ), ) @@ -1124,15 +1144,15 @@ export function ExpenseForm({ }} inputMode={ form.getValues().splitMode === - 'BY_AMOUNT' + 'BY_AMOUNT' ? 'decimal' : 'numeric' } step={ form.getValues().splitMode === - 'BY_AMOUNT' + 'BY_AMOUNT' ? 10 ** - -groupCurrency.decimal_digits + -groupCurrency.decimal_digits : 1 } /> diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx new file mode 100644 index 000000000..077739929 --- /dev/null +++ b/src/components/ui/input-group.tsx @@ -0,0 +1,170 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +