Browse Source

implemented editing of document in GUI + respective handlers in backend

feature/document
Stephan Richter 4 months ago
parent
commit
26fa72ef84
  1. 32
      documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java
  2. 4
      documents/src/main/java/de/srsoftware/umbrella/documents/SqliteDb.java
  3. 18
      documents/src/main/java/de/srsoftware/umbrella/documents/model/Document.java
  4. 15
      frontend/src/Components/LineEditor.svelte
  5. 20
      frontend/src/Components/MarkdownEditor.svelte
  6. 12
      frontend/src/Components/MultilineEditor.svelte
  7. 16
      frontend/src/Components/PriceEditor.svelte
  8. 37
      frontend/src/routes/document/Position.svelte
  9. 4
      frontend/src/routes/document/PositionList.svelte
  10. 103
      frontend/src/routes/document/View.svelte
  11. 3
      translations/src/main/resources/de.json
  12. 7
      web/src/main/resources/web/css/default.css

32
documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java

@ -5,6 +5,7 @@ import static de.srsoftware.tools.MimeType.MIME_FORM_URL; @@ -5,6 +5,7 @@ import static de.srsoftware.tools.MimeType.MIME_FORM_URL;
import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.Paths.LIST;
import static de.srsoftware.umbrella.core.ResponseCode.HTTP_UNPROCESSABLE;
import static de.srsoftware.umbrella.core.Util.request;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.forbidden;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingFieldException;
@ -111,6 +112,23 @@ public class DocumentApi extends BaseHandler { @@ -111,6 +112,23 @@ public class DocumentApi extends BaseHandler {
return sendEmptyResponse(HTTP_OK,addCors(ex));
}
@Override
public boolean doPatch(Path path, HttpExchange ex) throws IOException {
addCors(ex);
try {
Optional<Token> token = SessionToken.from(ex).map(Token::of);
var user = users.loadUser(token);
if (user.isEmpty()) return unauthorized(ex);
var head = path.pop();
var docId = Long.parseLong(head);
return patchDocument(docId,user.get(),ex);
} catch (NumberFormatException n){
return sendContent(ex,HTTP_UNPROCESSABLE,"Invalid document id");
} catch (UmbrellaException e) {
return send(ex,e);
}
}
@Override
public boolean doPost(Path path, HttpExchange ex) throws IOException {
addCors(ex);
@ -187,6 +205,20 @@ public class DocumentApi extends BaseHandler { @@ -187,6 +205,20 @@ public class DocumentApi extends BaseHandler {
}
}
private boolean patchDocument(long docId, UmbrellaUser user, HttpExchange ex) throws UmbrellaException, IOException {
var doc = db.loadDoc(docId);
var companyId = doc.companyId();
if (!companies.membership(companyId,user.id())) throw forbidden("You are mot a member of company {0}",doc.companyId());
var json = json(ex);
for (var key : json.keySet()){
var value = json.get(key);
LOG.log(WARNING,"{0} : {1}",key,value);
}
doc.patch(json);
db.save(doc);
return ok(ex);
}
private boolean postDocument(HttpExchange ex, UmbrellaUser user) throws IOException, UmbrellaException {
var json = json(ex);
if (!(json.has(SENDER) && json.get(SENDER) instanceof JSONObject senderData)) throw missingFieldException(SENDER);

4
documents/src/main/java/de/srsoftware/umbrella/documents/SqliteDb.java

@ -438,10 +438,10 @@ CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255) @@ -438,10 +438,10 @@ CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255)
var sender = doc.sender();
var custom = doc.customer();
update(TABLE_DOCUMENTS)
.set(DATE, FIELD_DELIVERY_DATE,FIELD_FOOTER,FIELD_HEAD, NUMBER, STATE, SENDER,FIELD_TAX_NUMBER,FIELD_BANK_ACCOUNT,FIELD_COURT,FIELD_CUSTOMER,FIELD_CUSTOMER_EMAIL,FIELD_CUSTOMER_NUMBER,FIELD_CUSTOMER_TAX_NUMBER)
.set(DATE, FIELD_DELIVERY_DATE,FIELD_FOOTER,FIELD_HEAD, NUMBER, STATE, SENDER,FIELD_TAX_NUMBER,FIELD_BANK_ACCOUNT,FIELD_COURT,FIELD_CUSTOMER,FIELD_CUSTOMER_EMAIL,FIELD_CUSTOMER_NUMBER,FIELD_CUSTOMER_TAX_NUMBER,FIELD_TEMPLATE_ID)
.where(ID,equal(doc.id()))
.prepare(db)
.apply(timestamp,doc.delivery(),doc.footer(),doc.head(),doc.number(),doc.state().code(),sender.name(),sender.taxNumber(),sender.bankAccount(),sender.court(),custom.name(),custom.email(),custom.id(),custom.taxNumber())
.apply(timestamp,doc.delivery(),doc.footer(),doc.head(),doc.number(),doc.state().code(),sender.name(),sender.taxNumber(),sender.bankAccount(),sender.court(),custom.name(),custom.email(),custom.id(),custom.taxNumber(),doc.template().id())
.close();
sender.clean();
custom.clean();

18
documents/src/main/java/de/srsoftware/umbrella/documents/model/Document.java

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
package de.srsoftware.umbrella.documents.model;
import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.ResponseCode.HTTP_UNPROCESSABLE;
import static de.srsoftware.umbrella.core.Util.markdown;
import static de.srsoftware.umbrella.documents.Constants.*;
import static de.srsoftware.umbrella.documents.Constants.FIELD_CUSTOMER;
@ -11,6 +12,8 @@ import de.srsoftware.tools.Mappable; @@ -11,6 +12,8 @@ import de.srsoftware.tools.Mappable;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import org.json.JSONObject;
@ -54,7 +57,7 @@ public final class Document implements Mappable { @@ -54,7 +57,7 @@ public final class Document implements Mappable {
private final Type type;
private LocalDate date;
private State state;
private final Template template;
private Template template;
private final Sender sender;
private final Customer customer;
private final PositionList positions;
@ -182,18 +185,21 @@ public final class Document implements Mappable { @@ -182,18 +185,21 @@ public final class Document implements Mappable {
return number;
}
public void patch(JSONObject json) {
public void patch(JSONObject json) throws UmbrellaException {
for (var key : json.keySet()){
switch (key){
case FIELD_CUSTOMER: if (json.get(key) instanceof JSONObject nested) customer.patch(nested); break;
case FIELD_CURRENCY: currency = json.getString(key); break;
case DATE: date = LocalDate.parse(json.getString(key)); break;
case DATE: date = LocalDate.parse(json.getString(key)); break;
case FIELD_DELIVERY: delivery = json.getString(key); break;
case FIELD_FOOTER: footer = json.getString(key); break;
case FIELD_HEAD: head = json.getString(key); break;
case NUMBER: number = json.getString(key); break;
case SENDER: if (json.get(key) instanceof JSONObject nested) sender.patch(nested); break;
default: key = null;
case NUMBER: number = json.getString(key); break;
case SENDER: if (json.get(key) instanceof JSONObject nested) sender.patch(nested); break;
case STATE: state = State.of(json.getInt(key)).orElseThrow(() -> new UmbrellaException(HTTP_UNPROCESSABLE,"Invalid state")); break;
case FIELD_POS: if (json.get(key) instanceof JSONObject nested) positions.patch(nested); break;
case FIELD_TEMPLATE_ID: if (json.get(key) instanceof Number num) template = new Template(num.longValue(),companyId,null,null); break;
default: key = null;
}
if (key != null) dirtyFields.add(key);
}

15
frontend/src/Components/LineEditor.svelte

@ -1,13 +1,14 @@ @@ -1,13 +1,14 @@
<script>
import { activeField } from './field_sync.svelte.js';
let { editable = false, value = $bindable(null) } = $props();
let { editable = false, value = $bindable(null), onSet = (newVal) => {return true;} } = $props();
let editing = $state(false);
let editValue = value;
function applyEdit(){
value = editValue;
async function applyEdit(){
let success = await onSet(editValue);
if (success) value = editValue;
editing=false;
}
@ -30,11 +31,15 @@ @@ -30,11 +31,15 @@
</script>
<style>
input{
min-width: 40px;
min-height: 20px;
}
div{
min-width: 40px;
min-height: 20px;
}
div:hover{
.editable:hover{
border: 1px dotted;
}
</style>
@ -42,5 +47,5 @@ @@ -42,5 +47,5 @@
{#if editable && editing}
<input bind:value={editValue} onkeyup={typed} autofocus />
{:else}
<span onclick={startEdit}>{value}</span>
<div onclick={startEdit} class={{editable}}>{value}</div>
{/if}

20
frontend/src/Components/MarkdownEditor.svelte

@ -1,16 +1,20 @@ @@ -1,16 +1,20 @@
<script>
import { activeField } from './field_sync.svelte.js';
let { editable = false, value = $bindable(null) } = $props();
let { editable = false, value = $bindable(null), onSet = (newVal) => {} } = $props();
let editing = $state(false);
let editValue = $state({source:value.source,rendered:value.rendered});
let timer = null;
function applyEdit(){
value.source = editValue.source;
value.rendered = editValue.rendered;
editing = false;
async function applyEdit(){
let success = await onSet(editValue.source);
if (success) {
value.source = editValue.source;
value.rendered = editValue.rendered;
editing = false;
} else resetEdit();
}
function resetEdit(){
@ -54,12 +58,12 @@ @@ -54,12 +58,12 @@
min-width: 40px;
min-height: 20px;
}
div:hover{
div.editable:hover{
border: 1px dotted;
}
</style>
{#if editable && editing}
{#if editing}
<textarea bind:value={editValue.source} onkeyup={typed} autofocus></textarea>
{/if}
<div onclick={startEdit}>{@html editValue.rendered}</div>
<div onclick={startEdit} class={{editable}}>{@html editValue.rendered}</div>

12
frontend/src/Components/MultilineEditor.svelte

@ -1,14 +1,16 @@ @@ -1,14 +1,16 @@
<script>
import { activeField } from './field_sync.svelte.js';
let { editable = false, value = $bindable(null) } = $props();
let { editable = false, value = $bindable(null), onSet = (newVal) => {} } = $props();
let editing = $state(false);
let editValue = $state(value);
let timer = null;
function applyEdit(){
value = editValue;
async function applyEdit(){
let success = await onSet(editValue);
if (success) value = editValue;
editing = false;
}
@ -39,7 +41,7 @@ @@ -39,7 +41,7 @@
min-width: 40px;
min-height: 20px;
}
div:hover{
div.editable:hover{
border: 1px dotted;
}
</style>
@ -47,7 +49,7 @@ @@ -47,7 +49,7 @@
{#if editable && editing}
<textarea bind:value={editValue} onkeyup={typed} autofocus></textarea>
{:else}
<div onclick={startEdit}>
<div onclick={startEdit} class={{editable}}>
{#each value.split("\n") as line}
{line}<br/>
{/each}

16
frontend/src/Components/PriceEditor.svelte

@ -1,13 +1,14 @@ @@ -1,13 +1,14 @@
<script>
import { activeField } from './field_sync.svelte.js';
let { editable = false, currency, value = $bindable(null) } = $props();
let { editable = false, currency, value = $bindable(null), onSet = (newVal) => {} } = $props();
let editing = $state(false);
let editValue = value/100;
function applyEdit(){
value = editValue * 100;
async function applyEdit(){
let success = await onSet(editValue * 100);
if (success) value = editValue * 100;
editing = false;
}
@ -31,11 +32,18 @@ @@ -31,11 +32,18 @@
<style>
input{width:100px}
div{
min-width: 40px;
min-height: 20px;
}
.editable:hover{
border: 1px dotted;
}
</style>
{#if editable && editing}
<input type="number" step=".01" bind:value={editValue} onkeyup={typed} />&nbsp;{currency}
{:else}
<div onclick={startEdit}>{Number(value/100).toFixed(2)}&nbsp;{currency}</div>
<div onclick={startEdit} class={{editable}}>{Number(value/100).toFixed(2)}&nbsp;{currency}</div>
{/if}

37
frontend/src/routes/document/Position.svelte

@ -5,33 +5,38 @@ @@ -5,33 +5,38 @@
import LineEditor from '../../Components/LineEditor.svelte';
import MarkdownEditor from '../../Components/MarkdownEditor.svelte';
import PriceEditor from '../../Components/PriceEditor.svelte';
var { currency, editable, pos = $bindable(null) } = $props();
console.log(pos);
var { currency, editable, pos = $bindable(null), submit = (key,newVal) => {} } = $props();
let prefix = `pos.${pos.number}`
</script>
{#if pos}
<tr>
<td>{pos.number}</td>
<td>
<LineEditor bind:value={pos.item} editable={editable} />
<td class="item">
<LineEditor bind:value={pos.item} editable={editable} onSet={(val) => submit(`${prefix}.item`,val)} />
</td>
<td class="title">
<LineEditor bind:value={pos.title} editable={editable} />
<LineEditor bind:value={pos.title} editable={editable} onSet={(val) => submit(`${prefix}.title`,val)} />
</td>
<td>
<LineEditor bind:value={pos.amount} editable={editable} />
<td class="amount">
<LineEditor bind:value={pos.amount} editable={editable} onSet={(val) => submit(`${prefix}.amount`,val)} />
</td>
<td>
<LineEditor bind:value={pos.unit} editable={editable} />
<td class="unit">
<LineEditor bind:value={pos.unit} editable={editable} onSet={(val) => submit(`${prefix}.unit`,val)} />
</td>
<td class="price">
<PriceEditor bind:value={pos.unit_price} editable={editable} currency={currency} onSet={(val) => submit(`${prefix}.unit_price`,val)} /></td>
<td class="price">
{Number(pos.amount * pos.unit_price/100).toFixed(2)}&nbsp;{currency}
</td>
<td class="tax">
{pos.tax}&nbsp;%
</td>
<td>
<PriceEditor bind:value={pos.unit_price} editable={editable} currency={currency} /></td>
<td>{Number(pos.amount * pos.unit_price/100).toFixed(2)}&nbsp;{currency}</td>
<td>{pos.tax}&nbsp;%</td>
</tr>
<tr>
<td class="error"><br/></td>
<td colspan="6"><MarkdownEditor bind:value={pos.description} editable={editable} /></td>
<td class="move"><br/></td>
<td colspan="6" class="description">
<MarkdownEditor bind:value={pos.description} editable={editable} onSet={(val) => submit(`${prefix}.description`,val)} />
</td>
<td></td>
</tr>
{/if}

4
frontend/src/routes/document/PositionList.svelte

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
import { onMount } from 'svelte';
import { t } from '../../translations.svelte.js';
var { document = $bindable(null) } = $props();
var { document = $bindable(null), submit = (key,newVal) => {} } = $props();
let editable = $derived(document.state == 1);
</script>
@ -25,7 +25,7 @@ @@ -25,7 +25,7 @@
</thead>
<tbody>
{#each Object.entries(document.positions) as [id,pos]}
<Position currency={document.currency} bind:pos={document.positions[id]} editable={editable} />
<Position currency={document.currency} bind:pos={document.positions[id]} editable={editable} {submit} />
{/each}
<tr class="sums">
<td colspan="2"></td>

103
frontend/src/routes/document/View.svelte

@ -25,8 +25,12 @@ @@ -25,8 +25,12 @@
}
}
function changeState(newVal){
async function changeState(newVal){
let success = false;
if (doc.state == 1 || confirm(t('document.confirm_state'))){
success = await submit('state',newVal);
}
if (success) {
doc.state = newVal;
} else {
const dummy = doc.state;
@ -35,6 +39,28 @@ @@ -35,6 +39,28 @@
}
}
async function submit(path,newValue){
const parts = path.split('.');
if (parts.length<1) return false;
let data = newValue;
while (parts.length > 0){
const inner = data;
data = {};
data[parts.pop()] = inner;
}
try {
const url = `${location.protocol}//${location.host.replace('5173','8080')}/api/document/${doc.id}`;
const resp = await fetch(url,{
credentials:'include',
method:'PATCH',
body:JSON.stringify(data)
});
return resp.ok;
} catch (err){
return false;
}
}
onMount(loadDoc);
</script>
@ -43,97 +69,110 @@ @@ -43,97 +69,110 @@
{/if}
{#if doc}
<fieldset class="left">
<fieldset class="customer">
<legend>{t('document.customer')}</legend>
<table>
<tbody>
<tr>
<td colspan="2">
<MultilineEditor bind:value={doc.customer.name} editable={editable} />
<MultilineEditor bind:value={doc.customer.name} editable={editable} onSet={(val) => submit('customer.name',val)} />
</td>
</tr>
<tr>
<th>{t('document.customer_id')}:</th>
<td>
<LineEditor bind:value={doc.customer.id} editable={editable} />
<LineEditor bind:value={doc.customer.id} editable={editable} onSet={(val) => submit('customer.id',val)} />
</td>
</tr>
<tr>
<th>{t('document.tax_id')}:</th>
<td>
<LineEditor bind:value={doc.customer.tax_id} editable={editable} />
<LineEditor bind:value={doc.customer.tax_id} editable={editable} onSet={(val) => submit('customer.tax_id',val)} />
</td>
</tr>
<tr>
<th>{t('document.email')}:</th>
<td>
<LineEditor bind:value={doc.customer.email} editable={editable} />
<LineEditor bind:value={doc.customer.email} editable={editable} onSet={(val) => submit('customer.email',val)} />
</td>
</tr>
</tbody>
</table>
</fieldset>
<fieldset class="left">
<fieldset class="sender">
<legend>{t('document.sender')}</legend>
<table>
<tbody>
<tr>
<td colspan="2">
<MultilineEditor bind:value={doc.sender.name} editable={editable} />
<MultilineEditor bind:value={doc.sender.name} editable={editable} onSet={(val) => submit('sender.name',val)} />
</td>
</tr>
<tr>
<th>{t('document.court')}:</th>
<td>
<LineEditor bind:value={doc.sender.court} editable={editable} />
<LineEditor bind:value={doc.sender.court} editable={editable} onSet={(val) => submit('sender.court',val)} />
</td>
</tr>
<tr>
<th>{t('document.tax_id')}:</th>
<td>
<LineEditor bind:value={doc.sender.tax_id} editable={editable} />
<LineEditor bind:value={doc.sender.tax_id} editable={editable} onSet={(val) => submit('sender.tax_id',val)} />
</td>
</tr>
<tr>
<th>{t('document.bank_account')}:</th>
<td>
<MultilineEditor bind:value={doc.sender.bank_account} editable={editable} />
<MultilineEditor bind:value={doc.sender.bank_account} editable={editable} onSet={(val) => submit('sender.bank_account',val)} />
</td>
</tr>
</tbody>
</table>
</fieldset>
<fieldset class="left">
<fieldset class="invoice_data">
<legend>{t('document.type_'+doc.type)}</legend>
<div>
{t('document.number')}:
<LineEditor bind:value={doc.number} editable={editable} />
</div>
<div>
{t('document.state')}:
<StateSelector selected={doc.state} onchange={changeState} />
</div>
<div>
{t('document.date')}:
<LineEditor bind:value={doc.date} editable={editable} />
</div>
<div>
{t('document.delivery')}:
<LineEditor bind:value={doc.delivery} editable={editable} />
</div>
<div>{t('document.template')}: <TemplateSelector company={doc.company.id} bind:value={doc.template.id} /></div>
<table>
<tbody>
<tr>
<th>{t('document.number')}:</th>
<td><LineEditor bind:value={doc.number} editable={editable} onSet={(val) => submit('number',val)} /></td>
</tr>
<tr>
<th>{t('document.state')}:</th>
<StateSelector selected={doc.state} onchange={changeState} onSet={(val) => submit('state',val)} />
</tr>
<tr>
<th>{t('document.date')}:</th>
<LineEditor bind:value={doc.date} editable={editable} onSet={(val) => submit('date',val)} />
</tr>
<tr>
<th>{t('document.delivery')}:</th>
<LineEditor bind:value={doc.delivery} editable={editable} onSet={(val) => submit('delivery',val)} />
</tr>
<tr>
<th>{t('document.template')}:</th>
<td>
{#if editable}
<TemplateSelector company={doc.company.id} bind:value={doc.template.id} onchange={() => submit('template_id',doc.template.id)} />
{:else}
{doc.template.name}
{/if}
</td>
</tr>
</tbody>
</table>
</fieldset>
<fieldset class="clear">
<legend>{t('document.head')}</legend>
<MarkdownEditor bind:value={doc.head} editable={editable} />
<MarkdownEditor bind:value={doc.head} editable={editable} onSet={(val) => submit('head',val)} />
</fieldset>
<fieldset>
<legend>{t('document.positions')}</legend>
<PositionList bind:document={doc} />
<PositionList bind:document={doc} {submit} />
</fieldset>
<fieldset>
<legend>{t('document.footer')}</legend>
<MarkdownEditor bind:value={doc.footer} editable={editable} />
<MarkdownEditor bind:value={doc.footer} editable={editable} onSet={(val) => submit('footer',val)} />
</fieldset>
<fieldset>
<legend>{t('document.actions')}</legend>

3
translations/src/main/resources/de.json

@ -16,12 +16,15 @@ @@ -16,12 +16,15 @@
"date": "Datum",
"delete": "löschen",
"email": "E-Mail",
"footer": "Fuß-Text",
"gross_sum": "Brutto-Summe",
"head": "Kopf-Text",
"list": "Dokumente",
"list_of": "Dokumente von {0}",
"net_price": "Nettopreis",
"number": "Nummer",
"pos": "Pos",
"positions": "Positionen",
"select_company" : "Wählen Sie eine ihrer Firmen:",
"select_customer": "Kunde auswählen",
"sender": "Absender",

7
web/src/main/resources/web/css/default.css

@ -60,7 +60,12 @@ fieldset[tabindex="0"]{ @@ -60,7 +60,12 @@ fieldset[tabindex="0"]{
fieldset[tabindex="0"]:focus-within{
max-height: unset;
}
.left{
td, tr{
vertical-align: baseline;
}
.customer,
.sender,
.invoice_meta{
float: left;
}

Loading…
Cancel
Save