mirror of
https://github.com/tomru/pfadi-bussle.git
synced 2026-03-03 06:27:11 +01:00
Admin page for bill wit iron-session (#13)
This commit is contained in:
28
lib/authenticate.ts
Normal file
28
lib/authenticate.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
|
||||
export default function authenticate(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
) {
|
||||
const authHeader = req.headers.authorization
|
||||
|
||||
if (!authHeader) {
|
||||
res.setHeader('WWW-Authenticate', 'Basic')
|
||||
res.statusCode = 401
|
||||
return null
|
||||
}
|
||||
|
||||
const [username, password] = Buffer.from(authHeader.split(' ')[1], 'base64')
|
||||
.toString()
|
||||
.split(':')
|
||||
|
||||
// FIXME: pull admin password from env
|
||||
if (username === 'admin' || password === 'secret') {
|
||||
return { username: 'admin', role: 'admin' }
|
||||
}
|
||||
|
||||
res.setHeader('WWW-Authenticate', 'Basic')
|
||||
res.statusCode = 401
|
||||
res.end()
|
||||
return null
|
||||
}
|
||||
17
lib/session.ts
Normal file
17
lib/session.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { withIronSession } from 'next-iron-session'
|
||||
|
||||
const SESSION_SECRET =
|
||||
process.env.SESSION_SECRET || 'dev-env-default-secret-991823723'
|
||||
|
||||
export default function withSession(handler) {
|
||||
return withIronSession(handler, {
|
||||
password: SESSION_SECRET,
|
||||
cookieName: 'pfadi-bussle-cookie',
|
||||
cookieOptions: {
|
||||
// the next line allows to use the session in non-https environements like
|
||||
// Next.js dev mode (http://localhost:3000)
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/admin',
|
||||
},
|
||||
})
|
||||
}
|
||||
71
package-lock.json
generated
71
package-lock.json
generated
@@ -1345,6 +1345,35 @@
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"@hapi/b64": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-5.0.0.tgz",
|
||||
"integrity": "sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw==",
|
||||
"requires": {
|
||||
"@hapi/hoek": "9.x.x"
|
||||
}
|
||||
},
|
||||
"@hapi/boom": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.0.tgz",
|
||||
"integrity": "sha512-4nZmpp4tXbm162LaZT45P7F7sgiem8dwAh2vHWT6XX24dozNjGMg6BvKCRvtCUcmcXqeMIUqWN8Rc5X8yKuROQ==",
|
||||
"requires": {
|
||||
"@hapi/hoek": "9.x.x"
|
||||
}
|
||||
},
|
||||
"@hapi/bourne": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.0.0.tgz",
|
||||
"integrity": "sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg=="
|
||||
},
|
||||
"@hapi/cryptiles": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-5.1.0.tgz",
|
||||
"integrity": "sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==",
|
||||
"requires": {
|
||||
"@hapi/boom": "9.x.x"
|
||||
}
|
||||
},
|
||||
"@hapi/formula": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz",
|
||||
@@ -1355,6 +1384,18 @@
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.1.0.tgz",
|
||||
"integrity": "sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw=="
|
||||
},
|
||||
"@hapi/iron": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-6.0.0.tgz",
|
||||
"integrity": "sha512-zvGvWDufiTGpTJPG1Y/McN8UqWBu0k/xs/7l++HVU535NLHXsHhy54cfEMdW7EjwKfbBfM9Xy25FmTiobb7Hvw==",
|
||||
"requires": {
|
||||
"@hapi/b64": "5.x.x",
|
||||
"@hapi/boom": "9.x.x",
|
||||
"@hapi/bourne": "2.x.x",
|
||||
"@hapi/cryptiles": "5.x.x",
|
||||
"@hapi/hoek": "9.x.x"
|
||||
}
|
||||
},
|
||||
"@hapi/joi": {
|
||||
"version": "17.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-17.1.1.tgz",
|
||||
@@ -3422,6 +3463,11 @@
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18="
|
||||
},
|
||||
"co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
@@ -3548,6 +3594,11 @@
|
||||
"safe-buffer": "~5.1.1"
|
||||
}
|
||||
},
|
||||
"cookie": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
|
||||
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA=="
|
||||
},
|
||||
"copy-concurrently": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
|
||||
@@ -5169,6 +5220,15 @@
|
||||
"integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=",
|
||||
"dev": true
|
||||
},
|
||||
"iron-store": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/iron-store/-/iron-store-1.3.0.tgz",
|
||||
"integrity": "sha512-Ics0Xy/1TTjOhZFLkarOpWfXp4TJ7foiKBFC/aXJH1bKwafNKrEhrHNeHyA/Jqx57tlhpIbGoNC3RUB+rRIz8Q==",
|
||||
"requires": {
|
||||
"@hapi/iron": "^6.0.0",
|
||||
"clone": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"is-accessor-descriptor": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
|
||||
@@ -7647,6 +7707,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"next-iron-session": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/next-iron-session/-/next-iron-session-4.1.9.tgz",
|
||||
"integrity": "sha512-8AmKWSDXEHc35364zY4y4H0xWLA3f7pqFGBubiuDunxvh159am7sonMoFDPJ7y38UQ8Qa1AHrKMM8qgTGeuhjg==",
|
||||
"requires": {
|
||||
"clone": "^2.1.2",
|
||||
"cookie": "^0.4.1",
|
||||
"debug": "^4.1.1",
|
||||
"iron-store": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"next-tick": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"ics": "^2.25.0",
|
||||
"mongoose": "^5.10.8",
|
||||
"next": "^9.5.4",
|
||||
"next-iron-session": "^4.1.9",
|
||||
"react": "16.13.1",
|
||||
"react-calendar": "^3.1.0",
|
||||
"react-dom": "16.13.1",
|
||||
|
||||
320
pages/admin/booking/[uuid]/bill.tsx
Normal file
320
pages/admin/booking/[uuid]/bill.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { GetServerSideProps, NextApiHandler, NextApiRequest } from 'next'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Footer from '../../../../components/footer'
|
||||
import Header from '../../../../components/header'
|
||||
import Input from '../../../../components/input'
|
||||
import Select from '../../../../components/select'
|
||||
import { AdditionalCost, BillDocument } from '../../../../db/bill'
|
||||
import { BookingDocument } from '../../../../db/booking'
|
||||
import { BILL_STATUS, MILAGE_TARIFS } from '../../../../db/enums'
|
||||
import { getBookingByUUID, getMilageMax } from '../../../../db/index'
|
||||
import { dateFormatFrontend } from '../../../../helpers/date'
|
||||
import { getBillTotal } from '../../../../helpers/bill'
|
||||
import { getBookingStatus } from '../../../../helpers/booking'
|
||||
import authenticate from '../../../../lib/authenticate'
|
||||
import withSession from '../../../../lib/session'
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = withSession(
|
||||
async ({ req, res, params }) => {
|
||||
const { uuid: uuids } = params
|
||||
|
||||
const authenticatedUser = authenticate(req, res)
|
||||
if (!authenticatedUser) {
|
||||
// TODO: not sure if needed
|
||||
req?.session.destroy()
|
||||
return { props: {} }
|
||||
}
|
||||
|
||||
req.session.set('user', authenticatedUser)
|
||||
await req.session.save()
|
||||
|
||||
const uuid = Array.isArray(uuids) ? uuids[0] : uuids
|
||||
const booking = await getBookingByUUID(uuid)
|
||||
|
||||
if (!booking) {
|
||||
res.statusCode = 404
|
||||
res.end()
|
||||
return { props: {} }
|
||||
}
|
||||
|
||||
const milageMax = await getMilageMax()
|
||||
await booking.populate('booker').populate('bill').execPopulate()
|
||||
|
||||
// TODO: hack, not sure why _id is not serilizable
|
||||
const bookingJSON = JSON.parse(JSON.stringify(booking.toJSON()))
|
||||
return {
|
||||
props: { booking: bookingJSON, milageMax },
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const milageTarifOptions = Object.values(MILAGE_TARIFS).map((tarif) => {
|
||||
return (
|
||||
<option value={tarif} key={tarif}>
|
||||
{getTarifLabel(tarif)}
|
||||
</option>
|
||||
)
|
||||
})
|
||||
|
||||
const bookingStatusOptions = Object.values(BILL_STATUS).map((status) => {
|
||||
return (
|
||||
<option value={status} key={status}>
|
||||
{getBillStatusLabel(status)}
|
||||
</option>
|
||||
)
|
||||
})
|
||||
|
||||
function getTarifLabel(tarif: MILAGE_TARIFS) {
|
||||
switch (tarif) {
|
||||
case MILAGE_TARIFS.EXTERN:
|
||||
return 'Extern'
|
||||
case MILAGE_TARIFS.INTERN:
|
||||
return 'Intern'
|
||||
case MILAGE_TARIFS.NOCHARGE:
|
||||
return 'Frei von Kosten'
|
||||
default:
|
||||
return 'Keine'
|
||||
}
|
||||
}
|
||||
|
||||
function getBillStatusLabel(status: BILL_STATUS) {
|
||||
switch (status) {
|
||||
case BILL_STATUS.UNINVOICED:
|
||||
return 'Nicht gestellt'
|
||||
case BILL_STATUS.INVOICED:
|
||||
return 'Gestellt'
|
||||
case BILL_STATUS.PAID:
|
||||
return 'Bezahlt'
|
||||
default:
|
||||
return 'Unbekannt!!!'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBill(
|
||||
booking: BookingDocument,
|
||||
bill: {
|
||||
milageStart: number
|
||||
milageEnd: number
|
||||
milage?: number
|
||||
tarif: MILAGE_TARIFS
|
||||
additionalCosts: AdditionalCost[]
|
||||
status: BILL_STATUS
|
||||
}
|
||||
): Promise<BillDocument> {
|
||||
const response = await fetch(`/api/booking/${booking.uuid}/bill`, {
|
||||
method: booking.bill?._id ? 'PATCH' : 'POST',
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
referrerPolicy: 'no-referrer',
|
||||
body: JSON.stringify(bill),
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export default function BillPage({
|
||||
booking: bookingProp,
|
||||
milageMax,
|
||||
}: {
|
||||
booking: BookingDocument
|
||||
milageMax: number
|
||||
}) {
|
||||
const [booking, setBooking] = useState(bookingProp)
|
||||
const [milageStart, setMilageStart] = useState(
|
||||
booking?.bill?.milageStart || milageMax
|
||||
)
|
||||
const [milageEnd, setMilageEnd] = useState(booking?.bill?.milageEnd)
|
||||
const [tarif, setTarif] = useState(
|
||||
booking?.bill?.tarif || MILAGE_TARIFS.EXTERN
|
||||
)
|
||||
const [status, setStatus] = useState(booking?.bill?.status)
|
||||
const [additionalCosts, setAdditionalCosts] = useState([])
|
||||
const [storingInProgress, setStoringInProgress] = useState(false)
|
||||
const [storingError, setStoringError] = useState(null)
|
||||
const milage =
|
||||
(0 < milageStart && milageStart < milageEnd && milageEnd - milageStart) || 0
|
||||
const total = getBillTotal({ tarif, milage, additionalCosts })
|
||||
|
||||
// in case the props change, update the internal state
|
||||
useEffect(() => setBooking(bookingProp), [bookingProp])
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setStoringInProgress(true)
|
||||
setStoringError(null)
|
||||
|
||||
try {
|
||||
const bill = await saveBill(booking, {
|
||||
milageStart,
|
||||
milageEnd,
|
||||
milage,
|
||||
tarif,
|
||||
status,
|
||||
additionalCosts,
|
||||
})
|
||||
|
||||
booking.bill = bill
|
||||
setBooking(booking)
|
||||
} catch (error) {
|
||||
setStoringError('Buchung konnte nicht gespeichert werden!')
|
||||
console.error('Failed to store booking', error)
|
||||
}
|
||||
setStoringInProgress(false)
|
||||
}
|
||||
|
||||
const onAddAdditionalCost = function (
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
) {
|
||||
event.preventDefault()
|
||||
setAdditionalCosts([...additionalCosts, { name: '', value: 0 }])
|
||||
}
|
||||
|
||||
const onRemoveAdditionalCost = function (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
index: number
|
||||
) {
|
||||
event.preventDefault()
|
||||
setAdditionalCosts([
|
||||
...additionalCosts.slice(0, index),
|
||||
...additionalCosts.slice(index + 1),
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-3 flex flex-col min-h-screen">
|
||||
<Header />
|
||||
<main className="flex-grow">
|
||||
<h2 className="text-3xl">Abrechnung</h2>
|
||||
{booking && (
|
||||
<form className="form" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<strong>Buchungszeitraum:</strong>{' '}
|
||||
{dateFormatFrontend(new Date(booking.startDate))} -{' '}
|
||||
{dateFormatFrontend(new Date(booking.endDate))}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Bucher:</strong> {booking.booker.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Buchungsstatus:</strong> {getBookingStatus(booking)}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Anfangskilometer"
|
||||
name="milageStart"
|
||||
required
|
||||
value={milageStart}
|
||||
type="number"
|
||||
onChange={(e: React.ChangeEvent<React.ElementRef<'input'>>) =>
|
||||
setMilageStart(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Endkilometer"
|
||||
name="milageEnd"
|
||||
required
|
||||
value={milageEnd}
|
||||
type="number"
|
||||
onChange={(e: React.ChangeEvent<React.ElementRef<'input'>>) =>
|
||||
setMilageEnd(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
<Input label="Gefahren" name="milage" readOnly value={milage} />
|
||||
<Select
|
||||
label="Rate"
|
||||
value={tarif}
|
||||
onChange={(e) => setTarif(e.target.value as MILAGE_TARIFS)}
|
||||
>
|
||||
{milageTarifOptions}
|
||||
</Select>
|
||||
<div className="mb-3">
|
||||
<button
|
||||
className="ibtn btn-gray mr-3"
|
||||
onClick={onAddAdditionalCost}
|
||||
title="Zusätzliche Kosten hinzufügen"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<label className="flabel inline">Zusätzliche Kosten</label>
|
||||
</div>
|
||||
{additionalCosts.map((_, index) => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3" key={`label${index}`}>
|
||||
<button
|
||||
className="ibtn btn-gray mr-3"
|
||||
onClick={(event) =>
|
||||
onRemoveAdditionalCost(event, index)
|
||||
}
|
||||
title="Entfernen"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<label className="flabel inline">{`Kostenpunkt ${
|
||||
index + 1
|
||||
}`}</label>
|
||||
</div>
|
||||
<div className="ml-10 mb-3" key={`input{index}`}>
|
||||
<Input
|
||||
label={`Name`}
|
||||
name={`additionalCostName${index}`}
|
||||
key={`additionalCostName${index}`}
|
||||
value={additionalCosts[index].name}
|
||||
onChange={(event) => {
|
||||
const newAdditonalCosts = [...additionalCosts]
|
||||
newAdditonalCosts[index] = {
|
||||
value: newAdditonalCosts[index].value,
|
||||
name: event.target.value,
|
||||
}
|
||||
setAdditionalCosts(newAdditonalCosts)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={`Betrag`}
|
||||
name={`additionalCostValue${index}`}
|
||||
key={`additionalCostValue${index}`}
|
||||
value={additionalCosts[index].value}
|
||||
type="number"
|
||||
onChange={(event) => {
|
||||
const newAdditonalCosts = [...additionalCosts]
|
||||
newAdditonalCosts[index] = {
|
||||
name: newAdditonalCosts[index].name,
|
||||
value: Number(event.target.value),
|
||||
}
|
||||
setAdditionalCosts(newAdditonalCosts)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
<Input label="Summe" name="total" readOnly value={total} />
|
||||
</div>
|
||||
<Select
|
||||
label="Status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as BILL_STATUS)}
|
||||
>
|
||||
{bookingStatusOptions}
|
||||
</Select>
|
||||
{storingError && (
|
||||
<div className="error-message flex-grow mt-6">{storingError}</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-blue mt-3"
|
||||
disabled={storingInProgress}
|
||||
>
|
||||
Rechnung {booking.bill?._id ? 'Updaten' : 'Erstellen'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { BillDocument } from '../../../../db/bill'
|
||||
import { createBill, patchBill } from '../../../../db/index'
|
||||
import withSession from '../../../../lib/session'
|
||||
|
||||
export default async function userHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
export default withSession(async function billHandler(req, res) {
|
||||
const {
|
||||
method,
|
||||
query: { uuid: uuids },
|
||||
} = req
|
||||
|
||||
const bookingUUID = Array.isArray(uuids) ? uuids[0] : uuids
|
||||
|
||||
const user = req?.session.get('user')
|
||||
if (!user || user.role !== 'admin') {
|
||||
res.status(401).end('Your are unauthorized. Best to move along...')
|
||||
return
|
||||
}
|
||||
|
||||
let bill: BillDocument
|
||||
|
||||
switch (method) {
|
||||
@@ -40,4 +42,4 @@ export default async function userHandler(
|
||||
res.setHeader('Allow', ['POST'])
|
||||
res.status(405).end(`Method ${method} Not Allowed`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import { GetServerSideProps } from 'next'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Footer from '../../../components/footer'
|
||||
import Header from '../../../components/header'
|
||||
import Input from '../../../components/input'
|
||||
import Select from '../../../components/select'
|
||||
import { AdditionalCost, BillDocument } from '../../../db/bill'
|
||||
import { BookingDocument } from '../../../db/booking'
|
||||
import { BILL_STATUS, MILAGE_TARIFS } from '../../../db/enums'
|
||||
import { getBookingByUUID, getMilageMax } from '../../../db/index'
|
||||
import { dateFormatFrontend } from '../../../helpers/date'
|
||||
import { getBillTotal } from '../../../helpers/bill'
|
||||
import { getBookingStatus } from '../../../helpers/booking'
|
||||
|
||||
const milageTarifOptions = Object.values(MILAGE_TARIFS).map((tarif) => {
|
||||
return (
|
||||
<option value={tarif} key={tarif}>
|
||||
{getTarifLabel(tarif)}
|
||||
</option>
|
||||
)
|
||||
})
|
||||
|
||||
const bookingStatusOptions = Object.values(BILL_STATUS).map((status) => {
|
||||
return (
|
||||
<option value={status} key={status}>
|
||||
{getBillStatusLabel(status)}
|
||||
</option>
|
||||
)
|
||||
})
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const {
|
||||
res,
|
||||
params: { uuid: uuids },
|
||||
} = context
|
||||
const uuid = Array.isArray(uuids) ? uuids[0] : uuids
|
||||
const booking = await getBookingByUUID(uuid)
|
||||
await booking.populate('booker').populate('bill').execPopulate()
|
||||
|
||||
if (!booking) {
|
||||
res.statusCode = 404
|
||||
res.end()
|
||||
return { props: {} }
|
||||
}
|
||||
|
||||
const milageMax = await getMilageMax()
|
||||
|
||||
// TODO: hack, not sure why _id is not serilizable
|
||||
const bookingJSON = JSON.parse(JSON.stringify(booking.toJSON()))
|
||||
return {
|
||||
props: { booking: bookingJSON, milageMax },
|
||||
}
|
||||
}
|
||||
|
||||
function getTarifLabel(tarif: MILAGE_TARIFS) {
|
||||
switch (tarif) {
|
||||
case MILAGE_TARIFS.EXTERN:
|
||||
return 'Extern'
|
||||
case MILAGE_TARIFS.INTERN:
|
||||
return 'Intern'
|
||||
case MILAGE_TARIFS.NOCHARGE:
|
||||
return 'Frei von Kosten'
|
||||
default:
|
||||
return 'Keine'
|
||||
}
|
||||
}
|
||||
|
||||
function getBillStatusLabel(status: BILL_STATUS) {
|
||||
switch (status) {
|
||||
case BILL_STATUS.UNINVOICED:
|
||||
return 'Nicht gestellt'
|
||||
case BILL_STATUS.INVOICED:
|
||||
return 'Gestellt'
|
||||
case BILL_STATUS.PAID:
|
||||
return 'Bezahlt'
|
||||
default:
|
||||
return 'Unbekannt!!!'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBill(
|
||||
booking: BookingDocument,
|
||||
bill: {
|
||||
milageStart: number
|
||||
milageEnd: number
|
||||
milage?: number
|
||||
tarif: MILAGE_TARIFS
|
||||
additionalCosts: AdditionalCost[]
|
||||
status: BILL_STATUS
|
||||
}
|
||||
): Promise<BillDocument> {
|
||||
const response = await fetch(`/api/booking/${booking.uuid}/bill/`, {
|
||||
method: booking.bill?._id ? 'PATCH' : 'POST',
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
referrerPolicy: 'no-referrer',
|
||||
body: JSON.stringify(bill),
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export default function BillPage({
|
||||
booking: bookingProp,
|
||||
milageMax,
|
||||
}: {
|
||||
booking: BookingDocument
|
||||
milageMax: number
|
||||
}) {
|
||||
const [booking, setBooking] = useState(bookingProp)
|
||||
const [milageStart, setMilageStart] = useState(
|
||||
booking.bill?.milageStart || milageMax
|
||||
)
|
||||
const [milageEnd, setMilageEnd] = useState(booking.bill?.milageEnd)
|
||||
const [tarif, setTarif] = useState(
|
||||
booking.bill?.tarif || MILAGE_TARIFS.EXTERN
|
||||
)
|
||||
const [status, setStatus] = useState(booking.bill?.status)
|
||||
const [additionalCosts, setAdditionalCosts] = useState([])
|
||||
const [storingInProgress, setStoringInProgress] = useState(false)
|
||||
const [storingError, setStoringError] = useState(null)
|
||||
const milage =
|
||||
(0 < milageStart && milageStart < milageEnd && milageEnd - milageStart) || 0
|
||||
const total = getBillTotal({ tarif, milage, additionalCosts })
|
||||
|
||||
// in case the props change, update the internal state
|
||||
useEffect(() => setBooking(bookingProp), [bookingProp])
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setStoringInProgress(true)
|
||||
setStoringError(null)
|
||||
|
||||
try {
|
||||
const bill = await saveBill(booking, {
|
||||
milageStart,
|
||||
milageEnd,
|
||||
milage,
|
||||
tarif,
|
||||
status,
|
||||
additionalCosts,
|
||||
})
|
||||
|
||||
booking.bill = bill
|
||||
setBooking(booking)
|
||||
} catch (error) {
|
||||
setStoringError('Buchung konnte nicht gespeichert werden!')
|
||||
console.error('Failed to store booking', error)
|
||||
}
|
||||
setStoringInProgress(false)
|
||||
}
|
||||
|
||||
const onAddAdditionalCost = function (
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
) {
|
||||
event.preventDefault()
|
||||
setAdditionalCosts([...additionalCosts, { name: '', value: 0 }])
|
||||
}
|
||||
|
||||
const onRemoveAdditionalCost = function (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
index: number
|
||||
) {
|
||||
event.preventDefault()
|
||||
setAdditionalCosts([
|
||||
...additionalCosts.slice(0, index),
|
||||
...additionalCosts.slice(index + 1),
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-3 flex flex-col min-h-screen">
|
||||
<Header />
|
||||
<main className="flex-grow">
|
||||
<h2 className="text-3xl">Abrechnung</h2>
|
||||
<form className="form" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<strong>Buchungszeitraum:</strong>{' '}
|
||||
{dateFormatFrontend(new Date(booking.startDate))} -{' '}
|
||||
{dateFormatFrontend(new Date(booking.endDate))}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Bucher:</strong> {booking.booker.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Buchungsstatus:</strong> {getBookingStatus(booking)}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Anfangskilometer"
|
||||
name="milageStart"
|
||||
required
|
||||
value={milageStart}
|
||||
type="number"
|
||||
onChange={(e: React.ChangeEvent<React.ElementRef<'input'>>) =>
|
||||
setMilageStart(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Endkilometer"
|
||||
name="milageEnd"
|
||||
required
|
||||
value={milageEnd}
|
||||
type="number"
|
||||
onChange={(e: React.ChangeEvent<React.ElementRef<'input'>>) =>
|
||||
setMilageEnd(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
<Input label="Gefahren" name="milage" readOnly value={milage} />
|
||||
<Select
|
||||
label="Rate"
|
||||
value={tarif}
|
||||
onChange={(e) => setTarif(e.target.value as MILAGE_TARIFS)}
|
||||
>
|
||||
{milageTarifOptions}
|
||||
</Select>
|
||||
<div className="mb-3">
|
||||
<button
|
||||
className="ibtn btn-gray mr-3"
|
||||
onClick={onAddAdditionalCost}
|
||||
title="Zusätzliche Kosten hinzufügen"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<label className="flabel inline">Zusätzliche Kosten</label>
|
||||
</div>
|
||||
{additionalCosts.map((_, index) => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3" key={`label${index}`}>
|
||||
<button
|
||||
className="ibtn btn-gray mr-3"
|
||||
onClick={(event) => onRemoveAdditionalCost(event, index)}
|
||||
title="Entfernen"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<label className="flabel inline">{`Kostenpunkt ${
|
||||
index + 1
|
||||
}`}</label>
|
||||
</div>
|
||||
<div className="ml-10 mb-3" key={`input{index}`}>
|
||||
<Input
|
||||
label={`Name`}
|
||||
name={`additionalCostName${index}`}
|
||||
key={`additionalCostName${index}`}
|
||||
value={additionalCosts[index].name}
|
||||
onChange={(event) => {
|
||||
const newAdditonalCosts = [...additionalCosts]
|
||||
newAdditonalCosts[index] = {
|
||||
value: newAdditonalCosts[index].value,
|
||||
name: event.target.value,
|
||||
}
|
||||
setAdditionalCosts(newAdditonalCosts)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={`Betrag`}
|
||||
name={`additionalCostValue${index}`}
|
||||
key={`additionalCostValue${index}`}
|
||||
value={additionalCosts[index].value}
|
||||
type="number"
|
||||
onChange={(event) => {
|
||||
const newAdditonalCosts = [...additionalCosts]
|
||||
newAdditonalCosts[index] = {
|
||||
name: newAdditonalCosts[index].name,
|
||||
value: Number(event.target.value),
|
||||
}
|
||||
setAdditionalCosts(newAdditonalCosts)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
<Input label="Summe" name="total" readOnly value={total} />
|
||||
</div>
|
||||
<Select
|
||||
label="Status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as BILL_STATUS)}
|
||||
>
|
||||
{bookingStatusOptions}
|
||||
</Select>
|
||||
{storingError && (
|
||||
<div className="error-message flex-grow mt-6">{storingError}</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-blue mt-3"
|
||||
disabled={storingInProgress}
|
||||
>
|
||||
Rechnung {booking.bill?._id ? 'Updaten' : 'Erstellen'}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.4 KiB |
Reference in New Issue
Block a user