init commit

This commit is contained in:
niko 2019-06-23 15:59:14 +02:00
commit 86ab51f672
19 changed files with 4232 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

19
app.js Normal file
View File

@ -0,0 +1,19 @@
const http = require('http');
const express = require('express');
const latex = require('node-latex')
const ejs = require('ejs');
const formidable = require('formidable');
const config = require('./config');
const app = express();
const path = require('path');
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '/templates'));
app.use('/public', express.static(path.join(__dirname, 'public')));
//TODO: secure headers
//TODO: ip ratelimits
app.use('/', require('./controllers/index'));
app.listen(config.port, () => console.log(`pdfgen is listening on port ${config.port}`))

5
config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
port: 3001,
tmpPath: '/tmp/',
autodelete_timer: 1000 * 60 * 10
}

42
controllers/api.js Normal file
View File

@ -0,0 +1,42 @@
const router = require('express').Router();
const {ensureJSON} = require('../helpers/middleware');
const request = require('request');
router.get('/cvr/:cvr', (req, res, next) => {
// TODO: ratelimits?
const cvr_regex = /^\d{8}$/g;
const cvr_api = cvr => `https://cvrapi.dk/api?search=${cvr}&country=dk`;
if (!cvr_regex.test(req.params.cvr)) {
res.statusCode = 400;
return next({name: 'Server-side validering', message: 'Forkert CVR format.'});
}
const cvr_api_options = {
url: cvr_api(req.params.cvr),
headers: {
'User-Agent': 'nyt projekt ved ik hvad det hedder endnu kontakt på n@nikobojs.com hvis problem eller nysgerrig, fakturagenerering'
}
};
request(cvr_api_options, (err, response, body) => {
if (err) return next('server fejl');
if (response.statusCode != 200){
response.statusCode = 500;
return next({name: 'cvr api returnerede en fejl', message: ''});
}
let json_body;
try {
json_body = JSON.parse(body);
res.json(json_body);
}
catch(e) {
res.statusCode = '500';
return next({name: 'cvrapi.dk returnerede ikke json', message: ''});
}
});
});
module.exports = router;

21
controllers/index.js Normal file
View File

@ -0,0 +1,21 @@
const router = require('express').Router();
router.get('/', (req, res) => {
res.redirect('/pdfgen/faktura');
})
router.use('/pdfgen', require('./pdfgen'));
router.use('/api', require('./api'));
router.use((error, req, res, next) => {
if(!res.headersSent){
if(res.statusCode === 200) res.statusCode = 500;
res.json(error);
}
});
router.use((req, res) => {
res.status(404).json({name: 'not found', message: ''});
});
module.exports = router;

123
controllers/pdfgen.js Normal file
View File

@ -0,0 +1,123 @@
const router = require('express').Router();
const {ensureJSON} = require('../helpers/middleware');
const request = require('request');
const ejs = require('ejs');
const formidable = require('formidable');
const config = require('../config');
const latex = require('node-latex');
const latexescape = require('../helpers/latexescape');
const fs = require('fs');
router.get('/:name', (req, res, next) => {
const template_name = req.params['name'].toLowerCase().replace(/[^a-z]+/g, '');
const template_conf = require(`../templates/${template_name}/conf.js`);
res.render(`${template_name}/formular.ejs`, {template: template_conf});
});
router.post('/upload/image', (req, res, next) => {
const form = new formidable.IncomingForm();
form.maxFields = 0;
form.maxFileSize = 2 * 1024 * 1024;
form.type = true;
let got_file = false;
let file_id;
form.parse(req)
form.on('fileBegin', (name, file) => {
if(got_file) return;
got_file = true;
const fileType = file.type.split('/').pop().toLowerCase();
if(!['png', 'jpg', 'jpeg'].includes(fileType)){
res.statusCode = 400;
form.emit('error', new Error('Ugyldig filtype'));
}
const file_id = randomString(24) + '.' + file.name.split('.').reverse()[0];
file.name = file_id;
file.path = config.tmpPath + file_id;
});
form.on('file', (name, file) => {
file_id = file.name;
});
form.on('end', () => {
if(!file_id) {
res.statusCode = 400;
return res.json({error: 'Du mangler at vælge en fil til upload'});
}
else{
autodelete(file_id);
return res.json({'fileid': file_id});
}
});
form.on('error', (err) => {
if(err.message && err.message.includes('maxFileSize exceeded')){
res.statusCode = 413;
res.json({name: 'Filen fylder for meget', message: ''});
req.socket.end();
}
else{
res.statusCode = res.statusCode == 200 ? 500 : res.statusCode;
res.json(err && err.message ? {error: err.message} : {});
req.socket.end();
}
});
});
router.post('/generate/:name', ensureJSON, async (req, res, next) => {
const template_name = req.params['name'].toLowerCase().replace(/[^a-z]+/g, '');
if(typeof(req.body.files) === 'object') {
Object.keys(req.body.files).map(f => {
req.body.files[f] = req.body.files[f].split('.');
req.body.files[f][0] = req.body.files[f][0].replace(/[^a-zA-Z0-9]+/g, '');
req.body.files[f] = config.tmpPath + req.body.files[f].join('.');
});
}
try {
const data = latexescape(req.body);
const conf = require(`../templates/${template_name}/conf.js`);
const rawlatex = await ejs.renderFile(`templates/${template_name}/${conf.latex}`, data, {async: true});
const pdf = latex(rawlatex);
pdf.pipe(res);
}
catch(e) {
return next({
name: 'Kunne ikke generere pdf\'en.',
message: 'Er du sikker på at du har udfyldt alle felterne korrekt?'
});
}
});
module.exports = router;
function randomString(len) {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
return alphabet[Math.floor(Math.random() * alphabet.length)] + (len > 1 ? randomString(--len) : '');
}
function autodelete(file_id, timeout = config.autodelete_timer){
const file_path = config.tmpPath + file_id;
console.log('setting filedelete timeout½')
setTimeout(() => {
try{
console.log('trying to unlink...')
fs.unlinkSync(file_path);
console.log('successfully deleted', file_path);
}
catch(e){
console.log('error deleting file:');
console.log(e);
// TODO: log errors silently
}
}, timeout);
}

30
helpers/latexescape.js Normal file
View File

@ -0,0 +1,30 @@
module.exports = obj => {
const clear_var = v => {
if(v == null || v == undefined) return '';
if(typeof(v) === 'object') return clear_obj(v);
else if(typeof(v) === 'string'){
v = v.replace(/(?![a-zA-Z0-9\s&/\-\.,\%\_#])./g, '');
v = v.replace(/&/g, '\\&');
v = v.replace(/%/g, '\\%');
v = v.replace(/_/g, '\\_');
v = v.replace(/#/g, '\\#');
return v;
}
else{
return v;
}
};
const clear_obj = o => {
Object.keys(o).map(k => o[k] = clear_var(o[k]));
return o;
};
const cleared = clear_obj(obj);
console.log('map clear var:');
console.log(cleared);
return cleared;
};

17
helpers/middleware.js Normal file
View File

@ -0,0 +1,17 @@
module.exports.ensureJSON = (req, res, next) => {
let data = "";
req.on('data', chunk => { data += chunk})
req.on('end', () => {
req.rawBody = data;
try{
req.body = JSON.parse(data);
next();
}
catch(e){
res.statusCode = 400;
console.log(req.body);
next(new Error('Unable to parse json'));
}
});
};

3263
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "pdfgen",
"version": "1.0.0",
"description": "",
"main": "app.js",
"author": "",
"license": "GPL-3.0-or-later",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node app.js",
"dev": "nodemon -V .",
"tmux": "./tmux_dev.sh"
},
"dependencies": {
"ejs": "^2.6.1",
"express": "^4.17.1",
"formidable": "^1.2.1",
"node-latex": "^2.6.0",
"request": "^2.88.0"
},
"devDependencies": {
"nodemon": "^1.19.1"
},
"nodemonConfig": {
"ignore": [
"public/*",
"templates/*"
]
}
}

BIN
public/ABeeZee-Regular.ttf Normal file

Binary file not shown.

93
public/css.css Normal file
View File

@ -0,0 +1,93 @@
@font-face {
font-family: 'abeezee';
src: url('/public/ABeeZee-Regular.ttf');/* Safari, Android, iOS */
}
body{
font-family: 'abeezee', sans-serif;
}
input.vali_err {
border: 1px solid #f00;
padding: 10px;
}
.vmargin{
margin: 20px 0px;
}
.fakturatable {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 20px;
}
.formular > div > div > div > label {
display: block;
margin-bottom: -45px;
color: #888;
font-size: .8rem;
font-weight: 500;
}
.fakturatable label {
color: #888;
font-size: .8rem;
font-weight: 500;
}
.formular > div{
margin-bottom: 60px;
}
.formular > div > div {
display: grid;
grid-template-columns: 1fr 1fr;
}
.formular > div > div > div {
min-height: 50px;
display: flex;
flex-direction: row;
align-items: center;
margin-right: 20px;
}
.formular{
max-width: 800px;
margin: 60px auto 100px auto;
background-color: #eff0fa;
padding: 40px;
}
input.small{
width: 100px;
margin-right: 16px;
}
#seller, #buyer{
display: block;
color: #888;
font-size: .8rem;
}
input:disabled{
opacity: 0.8;
}
.inline{
display: inline !important;
}
h1{
margin: 28px 0px 0px 0px;
font-size: 1.5rem;
}
footer{
max-width: 800px;
margin: 0px auto 60px auto;
padding: 0px;
color: #888;
font-size: .8em;
text-align: center;
}

138
public/js.js Normal file
View File

@ -0,0 +1,138 @@
console.log('hello js.js');
window.pdfgen = {};
const $ = str => {
const elems = document.querySelectorAll(str);
if(!elems) throw new Error(`DOM elem '${str}' not found`);
return (elems.length == 1) ? elems[0] : elems;
};
function error(err){
console.log('error(): ', err);
if(err.name && err.message){
alert(`${err.name}\n${err.message}`);
}
else if(typeof(err) === 'string'){
alert(err);
}
else{
console.error(err);
}
return undefined;
}
async function cvrapi(el){
const cvr = el.value;
let resjson = null;
if(!/\d{8}/g.test(cvr)) return error({
el: el,
name: 'Forkert CVR format',
message: ' '
});
const resobj = await fetch(`/api/cvr/${cvr}`);
console.log('resobj', resobj);
try{ resjson = await resobj.json(); }
catch(e) {
console.log('json error:', e);
return error({
el: el,
name: 'Kunne ikke parse respons body til JSON',
message: 'Serveren svarede ikke korrekt, skriv gerne en fejlrapport.'
});
}
if(!resobj.ok) return error({
el: el,
name: 'Server error',
message: el.error
});
return resjson;
}
function populate(){
return new Promise(async (resolve, reject) => {
const elems = Array.from(document.getElementsByClassName('var'));
// TODO: add text type using opts from dataset
await Promise.all(elems.map(async (el) => {
if(el.dataset.is === 'number'){
try {
const number = parseInt(el.value);
window.pdfgen[el.dataset.var] = number;
}
catch(e) { throw {
el: el,
name: 'Client-side input validering',
message: 'Værdien kunne ikke konverteres til et tal'
}; }
}
else if(el.dataset.is === 'boolean') {
if(typeof(el.checked) !== 'boolean') throw {
el: el,
name: 'Client-side input validering',
message: 'Checkboxens værdi er ikke gyldig :S'
};
window.pdfgen[el.dataset.var] = el.checked
}
else if(el.dataset.is === 'string'){
if(typeof(el.value) !== 'string' || el.value === '') throw{
el: el,
name: 'Client-side input validering',
message: 'Teksten kunne ikke valideres'
};
window.pdfgen[el.dataset.var] = el.value;
}
else if(el.dataset.is === 'file') {
if(!window.pdfgen.files) window.pdfgen.files = {};
let resjson;
const data = new FormData();
if(!el.files[0]) throw {
el: el,
name: 'Client-side input validering',
message: 'Du mangler at vælge en fil til upload'
};
data.append('file', el.files[0]);
const resobj = await fetch('/pdfgen/upload/image', {
method: 'POST',
body: data
});
try{ resjson = await resobj.json(); }
catch(e) {
throw {
el: el,
name: 'Kunne ikke parse respons body til JSON',
message: 'Serveren svarede ikke korrekt, skriv gerne en fejlrapport.'
};
}
if(!resobj.ok) {
// TODO: throw or reject?
if(!resjson.error) return reject(resjson);
return reject({
el: el,
name: 'Kunne ikke uploade fil',
message: resjson.error
});
}
window.pdfgen.files[el.dataset.var] = resjson.fileid;
}
})).then(() => resolve()).catch(e => {
if(e.el) {
e.el.className += ' vali_err';
setTimeout(() => e.el.className = e.el.className.replace(/\ vali_err/g, ''), 3800);
}
return reject(e);
});
});
}

View File

@ -0,0 +1,9 @@
</div>
<footer>
Dette projekt er open-source (<a href="https://www.gnu.org/licenses/gpl-3.0.html">GPLv3</a>) og kan frit benyttes til alle formål.<br />
Jeg lægger snart et link til git op, indtil videre må du kontakte mig hvis du er interesseret i koden.<br />
Hvis du vil støtte projektet med kode eller donationer, så kontakt mig på 'n snabel-a nikobojs.com'.
</footer>
</body>
</html>

10
templates/base/header.ejs Normal file
View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" type="text/css" href="/public/css.css" />
<title>frifaktura.dk - simpel faktura generering, uden reklamer eller dataindsamling.</title>
<script src="/public/js.js"></script>
</head>
<body>
<div class="formular">

17
templates/faktura/conf.js Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
/*
Køber CVR
Sælger CVR
Faktura nr
Konto nr
Reg nr
Fakturadato
Betalingsdato
Logo fileupload
Produkter/services [ Beskrivelse, Antal, Pris ]
*/
template: 'formular.ejs',
latex: 'latex.ejs',
author: 'nikobojs',
name: 'faktura'
}

View File

@ -0,0 +1,328 @@
<% include ../base/header %>
<div>
<h1>1. Basic info</h1>
<div>
<div>
<label>Sælger CVR:</label>
</div>
<div></div>
<div>
<input id="seller_cvr" class="small" type="text" data-is="number" />
<input id="seller_btn" type="button" value="Hent data fra cvrapi.dk" />
</div>
<div>
<span id="seller"></span>
</div>
</div>
<div>
<div>
<label>Køber CVR:</label>
</div>
<div></div>
<div>
<input id="buyer_cvr" class="small" type="text" data-is="number" />
<input id="buyer_btn" type="button" value="Hent data fra cvrapi.dk" />
</div>
<div>
<span id="buyer"></span>
</div>
</div>
<div>
<div>
<label>Faktura nummer:</label>
</div>
<div>
<label>Moms?</label>
</div>
<div>
<input class="var small" data-var="invoiceno" data-is="number" value="12312312" />
</div>
<div>
<input class="var" data-var="vat" data-is="boolean" type="checkbox" checked />
</div>
</div>
<div>
<div>
<label>Konto nr.:</label>
</div>
<div>
<label>Reg nr.:</label>
</div>
<div>
<input class="var small" data-var="accountno" data-is="number" value="12312312" />
</div>
<div>
<input class="var small" data-var="regno" data-is="number" value="12312312" />
</div>
<div>
<label>Faktueringsdato:</label>
</div>
<div>
<label>Betalingsdato:</label>
</div>
<div>
<input class="var small" data-var="sent_date" data-is="string" value="11/12/2019" />
</div>
<div>
<input class="var small" data-var="pay_date" data-is="string" value="13/12/2019" />
</div>
</div>
</div>
<div>
<h1>2. Upload logo</h1>
<div>
<div>
<label>Maks 2 megabytes</label>
</div>
<div></div>
<div>
<input class="var" data-is="file" data-var="logo" type="file" />
</div>
</div>
</div>
<!-- work array -->
<div>
<h1>3. Tilføj produkt/service</h1>
<section class="fakturatable">
<div><label>Beskrivelse</label></div>
<div><label>Antal</label></div>
<div><label>Enhedspris</label></div>
<div><label>Samlet</label></div>
<div><label></label></div>
</section>
<section id="added_work"></section>
<section class="fakturatable vmargin">
<div><input id="work_desc" class="small" /></div>
<div><input id="work_amou" class="small" /></div>
<div><input id="work_pric" class="small" /></div>
<div><button onclick="addWorkLine()">Tilføj linje</button></div>
<div></div>
</section>
</div>
<div>
<h1>4. Hent faktura</h1>
<div>
<div>
<input id="submit" type="button" value="Download PDF" />
</div>
</div>
</div>
<script>
function removeWorkLine(event, index){
window.pdfgen.work.splice(index, 1);
const target = event.target.parentNode.parentNode;
target.parendNode.removeChild(target);
}
function addWorkLine(){
// TODO: validation
let work = null;
const appendto = $('#added_work');
const container = document.createElement('div');
container.className = 'fakturatable';
try{
work = {
description: $('#work_desc').value,
units: parseFloat($('#work_amou').value),
unitprice: parseFloat($('#work_pric').value)
};
}
catch(e){
return error({name: 'Client-side validering', message: 'Ugyldigt bruger input'});
}
if(!window.pdfgen.work) window.pdfgen.work = [work];
else window.pdfgen.work.push(work);
console.log('added work', work, 'to window.pdfgen', window.pdfgen);
const description = document.createElement('div');
const units = document.createElement('div');
const unitprice = document.createElement('div');
const sum = document.createElement('div');
const remove = document.createElement('button');
remove.addEventListener('click', e => removeWorkLine(e, work.length - 1));
description.innerText = work.description;
units.innerText = work.units;
unitprice.innerText = work.unitprice;
sum.innerText = parseInt(work.units) * parseInt(work.unitprice);
container.appendChild(description);
container.appendChild(units);
container.appendChild(unitprice);
container.appendChild(sum);
appendto.appendChild(container);
}
/*window.pdfgen = {
"sellerasdasd": {
"name": "Nikobojs Web & Software",
"vat": 38273965,
"address": "Polensgade 19, 3. tv.",
"zipcode": "2300",
"city": "København S",
"phone": null,
"email": "nikolaj@fabriciusbjerre.dk",
"addressco": "Nikolaj Fabricius-Bjerre"
},
"buyer": {
"name": "Nikobojs Web & Software",
"vat": 38273965,
"address": "Polensgade 19, 3. tv.",
"city": "København S",
"zipcode": "2300"
},
"invoice_no": "2019-01-01T00:00:00.000Z"
};*/
$('#seller_btn').addEventListener('click', async (event) => {
$('#seller_cvr').disabled = true;
const data = await cvrapi($('#seller_cvr'));
$('#seller_cvr').disabled = false;
if(typeof(data) !== 'object') return;
window.pdfgen.seller = {
'name': data.name,
'vat': data.vat,
'address': data.address,
'city': data.city,
'zipcode': data.zipcode,
'phone': data.phone,
'email': data.email,
'addressco': data.addressco
};
$('#seller').innerHTML = `
${data.name}<br />
${data.address}, ${data.zipcode} ${data.city}<br />
CVR ${data.vat}<br />
`;
});
$('#buyer_btn').addEventListener('click', async (event) => {
$('#buyer_cvr').disabled = true;
const data = await cvrapi($('#buyer_cvr'));
$('#buyer_cvr').disabled = false;
if(typeof(data) !== 'object') return;
window.pdfgen.buyer = {
'name': data.name,
'vat': data.vat,
'city': data.city,
'address': data.address,
'zipcode': data.zipcode
};
$('#buyer').innerHTML = `
${data.name}<br />
${data.address}, ${data.zipcode} ${data.city}<br />
CVR ${data.vat}<br />
`;
});
window.addEventListener('DOMContentLoaded', e => {
// TODO: move to js.js? make formular.ejs simple!!!
$('#submit').addEventListener('click', async (event) => {
let response;
try{
await populate();
}
catch(e){
return error(e);
}
const url = `generate/<%= locals.template['name'] %>`;
try{
response = await fetch(url, {
method: 'POST',
cache: 'no-cache',
referrer: 'no-referrer',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(window.pdfgen)
});
}
catch(e){
return error(e);
}
console.log('response is', response);
if(!response.ok){
try{
const err = await response.json();
return error(err);
}
catch(e){
return error({
name: 'Kunne ikke parse respons body til JSON',
message: 'Serveren svarede ikke korrekt, skriv gerne en fejlrapport.'
});
}
}
const blob = await response.blob();
const objurl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objurl;
a.download = 'faktura.pdf'; // TODO
$('body').appendChild(a);
a.click();
a.remove();
});
});
var hello = {
'buyer': {
'name': {type: 'string', required: true},
'vat': {type: 'string', required: true},
'address': {type: 'string', required: true},
'zipcode': {type: 'string', required: true},
'phone': {type: 'string', required: false},
'email': {type: 'string', required: false},
'addressco': {type: 'string', required: false},
},
'seller': {
'name': {type: 'string', required: true},
'vat': {type: 'string', required: true},
'address': {type: 'string', required: true},
'zipcode': {type: 'string', required: true},
},
'invoiceno': {type: 'number', required: true},
'pay_date': {type: 'date', required: true},
'sent_date': {type: 'date', required: true},
'accountno': {type: 'number', required: true},
'regno': {type: 'number', required: true},
'moms': {type: 'boolean', required: true},
'work': [{
'description': {type: 'string', required: true},
'units': {type: 'number', required: true},
'unitprice': {type: 'float', required: true}
}],
'files': {
'logo': ['png', 'jpg', 'jpeg']
}
};
</script>
<% include ../base/footer %>

View File

@ -0,0 +1,75 @@
<%
function formatPrice(n){
n = n.toFixed(2);
n = n.replace('.', ',');
return n;
}
%>
\documentclass[a4paper]{article}
\usepackage{fancyhdr, graphicx, tabularx, changepage, multicol, array}
\usepackage[top=2.0cm, left=2.0cm, right=2.0cm, bottom=2.0cm, headheight=120.5pt, includeheadfoot]{geometry}
\usepackage[utf8]{inputenc}
\usepackage{makecell}
\usepackage{booktabs}
\renewcommand{\familydefault}{\sfdefault}
\title{Faktura}
\pagestyle{fancy}
\headsep=1.4cm
\renewcommand{\headrulewidth}{0pt}
\rhead{
\includegraphics[width=3.5cm]{<%= files.logo %>} \\\bigskip
<%- seller["name"] %> \\
<%= locals.seller['addressco'] ? ('c/o ' + seller["addressco"] + ' \\\\') : '\\\\' %>
CVR <%= seller["vat"] %> \\\smallskip
<%- seller["address"] %> \\
<%- seller["zipcode"] %> <%- seller["city"] %> \\\smallskip
<%= locals.seller['phone'] ? seller["phone"] + ' \\\\' : '%' %>
<%= locals.seller['email'] ? seller["email"] + ' \\\\' : '\\\\' %>
}
\fancyfoot{}
\newcolumntype{R}{>{\raggedleft\arraybackslash}p{3.2cm}}
\renewcommand{\arraystretch}{1.2}
\begin{document}
\vspace{4.5cm}
\begin{adjustwidth}{0.1cm}{0pt}
\begin{Huge}
\textbf{\textsf{FAKTURA}} \\
\end{Huge}
\end{adjustwidth}
\vspace{1cm}
\begin{center}
\begin{tabularx}{\textwidth}{ @{}X p{2.0cm} p{2.5cm} R@{}}
<%- buyer["name"] %> & & \textbf{Fakturanummer:} & <%= invoiceno %> \\
<%- buyer["address"] %>, <%- buyer["zipcode"] %> <%- buyer["city"] %> & & \textbf{Fakturadato:} & <%= sent_date %> \\
CVR <%= buyer["vat"] %> & & \textbf{Betalingsdato:} & <%= pay_date %> \\
& & \textbf{Konto nr.:} & <%= accountno %> \\
& & \textbf{Reg nr.:} & <%= regno %> \\ \\ \\ \\
\textbf{\textsf{Beskrivelse}} & \textbf{\textsf{Antal}} & \textbf{\textsf{Pris}} & \textbf{\textsf{Sum}} \\
\hline \\
<% for(line of work){ %>
<%= line.description %> & <%= formatPrice(line.units) %> & DKK <%= formatPrice(line.unitprice) %> & DKK <%= formatPrice(line.units*line.unitprice) %> \\
<% } %>
& & & \\
\hline \\
<% sum = work.reduce((sum, l) => sum += l.units*l.unitprice, 0) %>
<% if(vat) { %>
& & \textbf{Subtotal:} & DKK <%= formatPrice(sum) %>\\
& & \textbf{Moms $($25\%$)$:} & DKK <%= formatPrice(sum/4) %> \\ \\
& & \textbf{Total:} & \underline{DKK <%= formatPrice(sum + sum/4) %>}\\
<% } else { %>
& & \textbf{Total:} & \underline{DKK <%= formatPrice(sum) %>}\\
<% } %>
\end{tabularx}
\end{center}
\end{document}

11
tmux_dev.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/sh
tmux new-session \; \
send-keys "export PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '; clear" C-m \; \
send-keys "nodemon ." C-m \; \
split-window -v \; \
send-keys "export PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '; clear" C-m \; \
split-window -h \; \
send-keys "export PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '; clear" C-m \; \
send-keys 'htop' C-m \; \
select-pane -t 1 \;