feat(costs): create an expense from a booking, fix editing total-only items

Replace the inline price + budget-category fields in the Transport and
Reservation booking modals with a "Create expense" flow: the modal saves the
booking, then opens the full Costs editor prefilled (name + category mapped from
the booking type) and linked to the reservation. A booking with a linked expense
shows it inline with edit / remove.

Also fix the Costs editor so an expense with a recorded total but no payers
(transport-derived or pre-rework items) opens with its amount, lets you set the
currency, and saves - it previously showed 0 everywhere and could not be saved.
Legacy / localized categories now map to the fixed keys, and changing a booking's
type keeps its linked expense category in sync (unless it was manually set).

- shared: reservation_id on budget create, typeToCostCategory helper, i18n keys
- server: createBudgetItem stores reservation_id; keep total_price for payerless
  items; a booking update no longer wipes its linked expense and syncs the
  category on type change
- client: shared BookingCostsSection, exported ExpenseModal with prefill and an
  editable total, page-level save-then-open wiring
This commit is contained in:
Maurice
2026-06-17 22:11:56 +02:00
parent f98058a3af
commit c15c89ca61
34 changed files with 1014 additions and 465 deletions
+30 -12
View File
@@ -126,37 +126,55 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Tambah pemesanan',
'reservations.import.title': 'Impor konfirmasi pemesanan',
'reservations.import.cta': 'Impor dari file',
'reservations.import.dropHere': 'Seret file konfirmasi pemesanan ke sini atau klik untuk memilih',
'reservations.import.dropHere':
'Seret file konfirmasi pemesanan ke sini atau klik untuk memilih',
'reservations.import.dropActive': 'Lepaskan file untuk mengimpor',
'reservations.import.acceptedFormats': 'Diterima: EML, PDF, PKPass, HTML, TXT (maks. 10 MB per file, hingga 5 file)',
'reservations.import.acceptedFormats':
'Diterima: EML, PDF, PKPass, HTML, TXT (maks. 10 MB per file, hingga 5 file)',
'reservations.import.parsing': 'Memproses file…',
'reservations.import.previewHeading': '{count} pemesanan ditemukan',
'reservations.import.previewEmpty': 'Tidak ada pemesanan yang dapat diekstrak dari file yang diunggah.',
'reservations.import.previewEmpty':
'Tidak ada pemesanan yang dapat diekstrak dari file yang diunggah.',
'reservations.import.removeItem': 'Hapus',
'reservations.import.confirm': 'Impor {count} pemesanan',
'reservations.import.back': 'Kembali',
'reservations.import.success': '{count} pemesanan berhasil diimpor',
'reservations.import.partialFailure': '{created} berhasil diimpor, {failed} gagal',
'reservations.import.error': 'Pemrosesan gagal. Pastikan file adalah konfirmasi pemesanan yang valid.',
'reservations.import.unavailable': 'Impor pemesanan tidak tersedia di server ini.',
'reservations.import.unsupportedFormat': 'Format file tidak didukung. Gunakan EML, PDF, PKPass, HTML, atau TXT.',
'reservations.import.partialFailure':
'{created} berhasil diimpor, {failed} gagal',
'reservations.import.error':
'Pemrosesan gagal. Pastikan file adalah konfirmasi pemesanan yang valid.',
'reservations.import.unavailable':
'Impor pemesanan tidak tersedia di server ini.',
'reservations.import.unsupportedFormat':
'Format file tidak didukung. Gunakan EML, PDF, PKPass, HTML, atau TXT.',
'reservations.import.fileTooLarge': 'File "{name}" melebihi batas 10 MB.',
'reservations.airtrail.title': 'Impor dari AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Tersinkron dari AirTrail — perubahan tetap sinkron di kedua arah.',
'reservations.airtrail.syncedHint':
'Tersinkron dari AirTrail — perubahan tetap sinkron di kedua arah.',
'reservations.airtrail.notSynced': 'Tidak tersinkron',
'reservations.airtrail.notSyncedHint': 'Penerbangan ini telah dihapus di AirTrail dan tidak lagi tersinkron.',
'reservations.airtrail.loadError': 'Tidak dapat memuat penerbangan AirTrail-mu.',
'reservations.airtrail.notSyncedHint':
'Penerbangan ini telah dihapus di AirTrail dan tidak lagi tersinkron.',
'reservations.airtrail.loadError':
'Tidak dapat memuat penerbangan AirTrail-mu.',
'reservations.airtrail.imported': '{count} penerbangan diimpor',
'reservations.airtrail.skippedDuplicate': '{count} sudah ada di perjalanan ini, dilewati',
'reservations.airtrail.skippedDuplicate':
'{count} sudah ada di perjalanan ini, dilewati',
'reservations.airtrail.nothingImported': 'Tidak ada yang dapat diimpor.',
'reservations.airtrail.importError': 'Impor gagal. Silakan coba lagi.',
'reservations.airtrail.undo': 'Impor dari AirTrail',
'reservations.airtrail.alreadyImported': 'Diimpor',
'reservations.airtrail.duringTrip': 'Selama perjalanan ini',
'reservations.airtrail.otherFlights': 'Penerbangan lain',
'reservations.airtrail.empty': 'Tidak ada penerbangan ditemukan di akun AirTrail-mu.',
'reservations.airtrail.empty':
'Tidak ada penerbangan ditemukan di akun AirTrail-mu.',
'reservations.airtrail.importCta': 'Impor {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;