diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9ad15ad9..847a90d7 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3483,7 +3483,12 @@ "reference": "Referenz (optional)", "confirmPaid": "Als bezahlt markieren", "paidToast": "Als bezahlt markiert.", - "unpaidToast": "Als nicht bezahlt markiert." + "unpaidToast": "Als nicht bezahlt markiert.", + "addExpense": "Aufwand hinzufügen", + "addTitle": "Aufwand hinzufügen", + "description": "Beschreibung", + "descriptionHint": "z. B. Kilometer, Spesenpauschale, Barbeleg", + "createdToast": "Aufwand hinzugefügt." } }, "calendar": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index dd7f6c5b..8856f09f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3483,7 +3483,12 @@ "reference": "Reference (optional)", "confirmPaid": "Mark paid", "paidToast": "Marked as paid.", - "unpaidToast": "Marked as not paid." + "unpaidToast": "Marked as not paid.", + "addExpense": "Add expense", + "addTitle": "Add expense", + "description": "Description", + "descriptionHint": "e.g. mileage, per-diem, cash receipt", + "createdToast": "Expense added." } }, "calendar": { diff --git a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx index 9b091532..9701f5ec 100644 --- a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx +++ b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx @@ -11,14 +11,17 @@ import { Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { CheckCircle2, Circle, X, ExternalLink } from 'lucide-react'; +import { CheckCircle2, Circle, X, ExternalLink, Plus } from 'lucide-react'; import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common'; +import { DecimalInput } from '../../../components/common/DecimalInput'; +import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker'; import { formatMoneyMinor } from '../../../utils/money'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { accountingService, type Expense, type Disposition, + type MarkupType, type PaymentMethod, } from '../../../services/accounting.service'; @@ -80,6 +83,134 @@ const PayModal: React.FC<{ expense: Expense; onClose: () => void; onDone: () => ); }; +const MANUAL_DISPOSITIONS: Disposition[] = ['eigener_aufwand', 'durchlaufend', 'rebill']; + +const AddExpenseModal: React.FC<{ onClose: () => void; onDone: () => void }> = ({ onClose, onDone }) => { + const { t } = useTranslation(); + const [supplier, setSupplier] = useState(''); + const [description, setDescription] = useState(''); + const [amountMajor, setAmountMajor] = useState(NaN); + const [currency, setCurrency] = useState('CHF'); + const [disposition, setDisposition] = useState('eigener_aufwand'); + const [categoryId, setCategoryId] = useState(undefined); + const [customer, setCustomer] = useState([]); + const [eventId, setEventId] = useState(''); + const [markupType, setMarkupType] = useState('none'); + const [markupValue, setMarkupValue] = useState(NaN); + + const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() }); + const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; + const selectClass = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; + + const save = useMutation({ + mutationFn: async () => { + const expense = await accountingService.createExpense({ + disposition, + supplierName: supplier || null, + description: description || null, + chfAmountMinor: totalMinor, + grossAmountMinor: totalMinor, + categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null, + eventId: eventId ? Number(eventId) : null, + customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null, + markupType, + markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, + markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, + }); + if (disposition === 'rebill') { + await accountingService.rebill(expense.id, { + customerAccountId: customer[0].id, + eventId: eventId ? Number(eventId) : null, + markupType, + markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, + markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, + }); + } + }, + onSuccess: () => { toast.success(t('accounting.ledger.createdToast', 'Expense added.')); onDone(); }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + + const rebillNeedsCustomer = disposition === 'rebill' && !customer[0]; + + return ( +
+
+
+

{t('accounting.ledger.addTitle', 'Add expense')}

+ +
+
+
+ + setSupplier(e.target.value)} /> +
+
+ + setDescription(e.target.value)} placeholder={t('accounting.ledger.descriptionHint', 'e.g. mileage, per-diem, cash receipt')} /> +
+
+
+ + +
+
+ + setCurrency(e.target.value.toUpperCase())} maxLength={3} /> +
+
+
+ + +
+ {disposition === 'eigener_aufwand' && ( +
+ + +
+ )} + {disposition === 'rebill' && ( +
+
+ + setCustomer(next.slice(-1))} /> +
+
+
+ + setEventId(e.target.value.replace(/[^0-9]/g, ''))} inputMode="numeric" /> +
+
+ + +
+
+ {markupType !== 'none' && ( + + )} +
+ )} +
+
+ + +
+
+
+ ); +}; + export const ExpensesLedgerPage: React.FC = () => { const { t } = useTranslation(); const qc = useQueryClient(); @@ -87,6 +218,7 @@ export const ExpensesLedgerPage: React.FC = () => { const [status, setStatus] = useState(''); const [disposition, setDisposition] = useState(''); const [payExpense, setPayExpense] = useState(null); + const [showAdd, setShowAdd] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['accounting-expenses', status, disposition], @@ -117,6 +249,9 @@ export const ExpensesLedgerPage: React.FC = () => { {DISPOSITIONS.map((d) => )} + {isLoading ? ( @@ -167,6 +302,10 @@ export const ExpensesLedgerPage: React.FC = () => { {payExpense && ( setPayExpense(null)} onDone={() => { setPayExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} /> )} + + {showAdd && ( + setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} /> + )} ); }; diff --git a/frontend/src/services/accounting.service.ts b/frontend/src/services/accounting.service.ts index 5085d7a4..ed77eb6b 100644 --- a/frontend/src/services/accounting.service.ts +++ b/frontend/src/services/accounting.service.ts @@ -70,6 +70,7 @@ export interface Paginated { export interface CategorizePayload { disposition: Disposition; supplierName?: string | null; + description?: string | null; chfAmountMinor?: number | null; netAmountMinor?: number | null; vatAmountMinor?: number | null; @@ -138,6 +139,12 @@ export const accountingService = { return data.expense; }, + // Create a manual expense (no inbound document). + async createExpense(payload: CategorizePayload): Promise { + const { data } = await api.post('/admin/expenses', payload); + return data.expense; + }, + async rebill(expenseId: number, payload: RebillPayload): Promise<{ expense: Expense; invoiceId: number }> { const { data } = await api.post(`/admin/expenses/${expenseId}/rebill`, payload); return data;