feat(accounting): event booking via dropdown (Company or an event)

Replaces the Company/Event toggle + numeric Event-ID input with a single
EventBookingSelect dropdown (Company = null, else a specific event, fetched via
eventsService). Used by both the incoming-invoice triage and the expense add
form. Projects stay a separate aggregation of events and are intentionally not
a booking target here.

Verified: tsc --noEmit clean; npm run build green.
This commit is contained in:
Luca
2026-06-11 15:17:43 +02:00
parent 2b7495e4dc
commit 81af4453e7
3 changed files with 48 additions and 29 deletions
@@ -0,0 +1,39 @@
/**
* Booking-target dropdown for accounting (expenses + incoming invoices).
* "Company" (value null) or a specific event. Projects remain a separate
* aggregation of events and are intentionally NOT a booking target here.
*/
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { eventsService } from '../../services/events.service';
interface Props {
value: number | null;
onChange: (eventId: number | null) => void;
className?: string;
}
export const EventBookingSelect: React.FC<Props> = ({ value, onChange, className }) => {
const { t } = useTranslation();
const { data } = useQuery({
queryKey: ['events-for-booking'],
queryFn: () => eventsService.getEvents(1, 200),
staleTime: 60_000,
});
const events = data?.events ?? [];
const cls = className
|| 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
return (
<select className={cls} value={value == null ? '' : String(value)}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}>
<option value="">{t('accounting.booking.company', 'Company')}</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.event_name}</option>
))}
</select>
);
};
export default EventBookingSelect;
@@ -13,6 +13,7 @@ import { Camera, Upload, Inbox, X, CheckCircle2, Circle } 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 { EventBookingSelect } from '../../../components/admin/EventBookingSelect';
import { formatMoneyMinor } from '../../../utils/money';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import {
@@ -34,27 +35,6 @@ const statusClasses: Record<string, string> = {
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const selectCls = '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 BookingField: React.FC<{ eventId: number | null; onChange: (id: number | null) => void }> = ({ eventId, onChange }) => {
const { t } = useTranslation();
const isEvent = eventId != null;
return (
<div>
<label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
<div className="flex gap-2">
<select className={selectCls} style={{ maxWidth: 160 }} value={isEvent ? 'event' : 'company'}
onChange={(e) => onChange(e.target.value === 'event' ? (eventId ?? 0) : null)}>
<option value="company">{t('accounting.booking.company', 'Company')}</option>
<option value="event">{t('accounting.booking.event', 'Event')}</option>
</select>
{isEvent && (
<Input placeholder={t('accounting.booking.eventId', 'Event ID')} inputMode="numeric"
value={eventId ? String(eventId) : ''} onChange={(e) => onChange(Number(e.target.value.replace(/[^0-9]/g, '')) || 0)} />
)}
</div>
</div>
);
};
const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; onDone: () => void }> = ({ doc, onClose, onDone }) => {
const { t } = useTranslation();
const [paidAt, setPaidAt] = useState('');
@@ -184,7 +164,12 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
</select>
</div>
{BOOKING_DISPOSITIONS.includes(disposition) && <BookingField eventId={eventId} onChange={setEventId} />}
{BOOKING_DISPOSITIONS.includes(disposition) && (
<div>
<label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
<EventBookingSelect value={eventId} onChange={setEventId} className={selectCls} />
</div>
)}
{disposition === 'eigener_aufwand' && (
<div><label className={labelCls}>{t('accounting.inbox.field.category', 'Category')}</label>
@@ -13,6 +13,7 @@ import { toast } from 'react-toastify';
import { X, Plus, Paperclip, Car, CalendarDays, Coins } from 'lucide-react';
import { Button, Card, CardContent, Input, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { EventBookingSelect } from '../../../components/admin/EventBookingSelect';
import { formatMoneyMinor } from '../../../utils/money';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import {
@@ -106,13 +107,7 @@ const AddExpenseModal: React.FC<{ categories: ExpenseCategory[]; onClose: () =>
</div>
<div><label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
<div className="flex gap-2">
<select className={selectCls} style={{ maxWidth: 160 }} value={eventId != null ? 'event' : 'company'} onChange={(e) => setEventId(e.target.value === 'event' ? (eventId ?? 0) : null)}>
<option value="company">{t('accounting.booking.company', 'Company')}</option>
<option value="event">{t('accounting.booking.event', 'Event')}</option>
</select>
{eventId != null && <Input placeholder={t('accounting.booking.eventId', 'Event ID')} inputMode="numeric" value={eventId ? String(eventId) : ''} onChange={(e) => setEventId(Number(e.target.value.replace(/[^0-9]/g, '')) || 0)} />}
</div>
<EventBookingSelect value={eventId} onChange={setEventId} className={selectCls} />
</div>
<div>