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/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": {
|
"@hapi/formula": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.1.0.tgz",
|
||||||
"integrity": "sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw=="
|
"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": {
|
"@hapi/joi": {
|
||||||
"version": "17.1.1",
|
"version": "17.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-17.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-17.1.1.tgz",
|
||||||
@@ -3422,6 +3463,11 @@
|
|||||||
"wrap-ansi": "^6.2.0"
|
"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": {
|
"co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@@ -3548,6 +3594,11 @@
|
|||||||
"safe-buffer": "~5.1.1"
|
"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": {
|
"copy-concurrently": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
|
||||||
@@ -5169,6 +5220,15 @@
|
|||||||
"integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=",
|
"integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=",
|
||||||
"dev": true
|
"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": {
|
"is-accessor-descriptor": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
|
"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": {
|
"next-tick": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"ics": "^2.25.0",
|
"ics": "^2.25.0",
|
||||||
"mongoose": "^5.10.8",
|
"mongoose": "^5.10.8",
|
||||||
"next": "^9.5.4",
|
"next": "^9.5.4",
|
||||||
|
"next-iron-session": "^4.1.9",
|
||||||
"react": "16.13.1",
|
"react": "16.13.1",
|
||||||
"react-calendar": "^3.1.0",
|
"react-calendar": "^3.1.0",
|
||||||
"react-dom": "16.13.1",
|
"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 { BillDocument } from '../../../../db/bill'
|
||||||
import { createBill, patchBill } from '../../../../db/index'
|
import { createBill, patchBill } from '../../../../db/index'
|
||||||
|
import withSession from '../../../../lib/session'
|
||||||
|
|
||||||
export default async function userHandler(
|
export default withSession(async function billHandler(req, res) {
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse
|
|
||||||
) {
|
|
||||||
const {
|
const {
|
||||||
method,
|
method,
|
||||||
query: { uuid: uuids },
|
query: { uuid: uuids },
|
||||||
} = req
|
} = req
|
||||||
|
|
||||||
const bookingUUID = Array.isArray(uuids) ? uuids[0] : uuids
|
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
|
let bill: BillDocument
|
||||||
|
|
||||||
switch (method) {
|
switch (method) {
|
||||||
@@ -40,4 +42,4 @@ export default async function userHandler(
|
|||||||
res.setHeader('Allow', ['POST'])
|
res.setHeader('Allow', ['POST'])
|
||||||
res.status(405).end(`Method ${method} Not Allowed`)
|
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