Compare commits

..

23 Commits

Author SHA1 Message Date
StephanRichter 747f829ded Merge branch 'main' into module/journal 2026-05-04 20:58:10 +02:00
StephanRichter 9417c60346 Merge branch 'accounting'
Build Docker Image / Docker-Build (push) Successful in 2m36s
Build Docker Image / Clean-Registry (push) Successful in 4s
2026-05-04 20:57:29 +02:00
StephanRichter f8db846369 Merge branch 'improvement/autocomplete'
Build Docker Image / Docker-Build (push) Successful in 3m3s
Build Docker Image / Clean-Registry (push) Successful in 4s
2026-05-04 20:53:48 +02:00
StephanRichter dee494f7ca implemented dropping transaction by clearing amount, source or destination field
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-04 15:27:51 +02:00
StephanRichter a261d8eb9b Merge branch 'main' into accounting 2026-05-04 15:20:40 +02:00
StephanRichter dbd84f193e working on translations
Build Docker Image / Docker-Build (push) Successful in 3m8s
Build Docker Image / Clean-Registry (push) Successful in 3s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-04 15:13:44 +02:00
StephanRichter fa395b5a33 gui improvements
Build Docker Image / Docker-Build (push) Successful in 3m1s
Build Docker Image / Clean-Registry (push) Successful in 4s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-04 14:38:57 +02:00
StephanRichter 854cdded3d implemented purpose proposals based on source, dest and amount
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-05-03 14:01:58 +02:00
StephanRichter 170a551169 adding title tag
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-25 20:15:05 +02:00
StephanRichter bf24b609c4 improved CSS for mobile devices
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-25 10:09:37 +02:00
StephanRichter 71309011a0 improving autocomplete
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-24 11:45:34 +02:00
StephanRichter 4ded399972 Merge branch 'main' into module/journal 2026-04-23 23:35:24 +02:00
StephanRichter cca8767dc8 usability improvement: no longer dropping tags when saving transaction
Build Docker Image / Docker-Build (push) Successful in 2m21s
Build Docker Image / Clean-Registry (push) Successful in 4s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-23 23:25:13 +02:00
StephanRichter 509accae3a minor improvement: automatically scrolling to bottom
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-23 20:17:17 +02:00
StephanRichter f42a99b2d6 Merge branch 'main' into accounting 2026-04-23 16:03:12 +02:00
StephanRichter 6ebc314a2b overhauled autocomplete – should now work with mobile devices, too
Build Docker Image / Docker-Build (push) Successful in 2m19s
Build Docker Image / Clean-Registry (push) Successful in 7s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-23 15:45:47 +02:00
StephanRichter 906e1d65f4 bugfix: search for tags failed, if only one transaction with the given parameters was present
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-23 11:09:46 +02:00
StephanRichter b5e8979ed9 improving usability by proposing tags
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-23 10:37:50 +02:00
StephanRichter db606dd20e GUI improvements
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-22 08:37:58 +02:00
StephanRichter 2eb3a19727 CSS improvement
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-22 08:28:00 +02:00
StephanRichter 8392edf408 Merge branch 'main' into module/journal 2026-04-22 08:26:07 +02:00
StephanRichter 9c80e0d77c preparing for journal with timestamps
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-22 08:24:54 +02:00
StephanRichter da5cb8e4e7 improved autocompletion
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-04-21 09:28:39 +02:00
17 changed files with 314 additions and 73 deletions
@@ -5,13 +5,18 @@ import de.srsoftware.umbrella.core.model.Account;
import de.srsoftware.umbrella.core.model.Transaction;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
public interface AccountDb {
void dropTransactionTag(long transactionId, String tag);
Optional<Transaction> lastTransaction(long accountId, String source, String dest, double amount);
Collection<Account> listAccounts(long userId);
Collection<String> listTags(long accountId, String source, String destination);
Account loadAccount(long accountId);
Transaction loadTransaction(long transactionId);
@@ -119,7 +119,7 @@ public class AccountingModule extends BaseHandler implements AccountingService {
var head = path.pop();
return switch (head) {
case null -> postEntry(user.get(),ex);
case DESTINATIONS -> postSearchDestinations(user.get(),ex);
case DESTINATIONS -> postSearchDestinations(user.get(),ex);
case PURPOSES -> postSearchPurposes(user.get(),ex);
case SOURCES -> postSearchSources(user.get(),ex);
default -> {
@@ -156,6 +156,25 @@ public class AccountingModule extends BaseHandler implements AccountingService {
return sendContent(ex, transaction);
}
private List<String> egalize(Set<String> tags, String key) {
var result = new HashSet<String>();
var lower = key.toLowerCase();
var len = key.length();
for (var tag : tags) {
if (tag.length() == key.length()) continue;
result.add(tag.toLowerCase().startsWith(lower) ? key + tag.substring(len) : tag);
}
return result.stream().sorted(String.CASE_INSENSITIVE_ORDER).toList();
}
private String extractParty(String field, JSONObject json){
if (!json.has(field)) return null;
if (!(json.get(field) instanceof JSONObject data)) return null;
if (data.has(Field.ID)) return data.get(Field.ID).toString();
if (data.has(Field.DISPLAY)) return data.get(Field.DISPLAY).toString();
return null;
}
private boolean getAccount(UmbrellaUser user, long accountId, HttpExchange ex) throws IOException {
LOG.log(WARNING,"Missing authorization check in AccountingModule.getAccount(…)!");
var account = accountDb.loadAccount(accountId);
@@ -276,6 +295,22 @@ public class AccountingModule extends BaseHandler implements AccountingService {
return sendContent(ex,newAccount != null ? newAccount : transaction);
}
public boolean postGetLastTransaction(long accountId, UmbrellaUser user, HttpExchange ex) throws IOException {
var json = json(ex);
if (!json.has(Field.SOURCE)) throw missingField(Field.SOURCE);
if (!(json.get(Field.SOURCE) instanceof JSONObject src)) throw invalidField(Field.SOURCE,JSON);
var source = src.get(src.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION);
if (!(json.get(Field.DESTINATION) instanceof JSONObject dst)) throw invalidField(Field.SOURCE,JSON);
var dest = dst.get(dst.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
if (!json.has(Field.AMOUNT)) throw missingField(Field.AMOUNT);
if (!(json.get(Field.AMOUNT) instanceof Number amt)) throw invalidField(Field.AMOUNT,Text.NUMBER);
var amount = amt.doubleValue();
var transaction = accountDb.lastTransaction(accountId, source, dest, amount);
return transaction.isPresent() ? sendContent(ex,transaction.get()) : notFound(ex);
}
public boolean postSearchDestinations(UmbrellaUser user, HttpExchange ex) throws IOException {
return sendContent(ex,searchOptions(user, Field.DESTINATION, body(ex)));
}
@@ -294,24 +329,23 @@ public class AccountingModule extends BaseHandler implements AccountingService {
private boolean postSearchTags(long accountId, UmbrellaUser user, HttpExchange ex) throws IOException {
LOG.log(WARNING,"Missing authorization check in AccountingModule.getAccount(…)!");
var key = body(ex);
var tags = accountDb.searchTagsContaining(key,accountId);
if (tags.size()<10) tags.addAll(tagService().search(key,user));
return sendContent(ex,egalize(tags,key));
}
private List<String> egalize(Set<String> tags, String key) {
var result = new HashSet<String>();
var lower = key.toLowerCase();
var len = key.length();
for (var tag : tags) {
if (tag.length() == key.length()) continue;
result.add(tag.toLowerCase().startsWith(lower) ? key + tag.substring(len) : tag);
if (!key.trim().startsWith("{")) { // search tags that contain value of body
var tags = accountDb.searchTagsContaining(key, accountId);
if (tags.size() < 10) tags.addAll(tagService().search(key, user));
return sendContent(ex, egalize(tags, key));
}
return result.stream().sorted(String.CASE_INSENSITIVE_ORDER).toList();
// search tags for account with specified source and destination
var json = new JSONObject(key);
var src = extractParty(Field.SOURCE, json);
var dst = extractParty(Field.DESTINATION, json);
var tags = accountDb.listTags(accountId,src,dst);
return sendContent(ex,tags);
}
private boolean postToAccount(long accountId, Path path, UmbrellaUser user, HttpExchange ex) throws IOException {
return switch (path.pop()) {
var head = path.pop();
return switch (head) {
case PURPOSES -> postGetLastTransaction(accountId,user,ex);
case TAGS -> postSearchTags(accountId,user,ex);
case null, default -> super.doPost(path,ex);
};
@@ -2,6 +2,7 @@
package de.srsoftware.umbrella.accounting;
import static de.srsoftware.tools.NotImplemented.notImplemented;
import static de.srsoftware.tools.Optionals.nullable;
import static de.srsoftware.tools.jdbc.Condition.*;
import static de.srsoftware.tools.jdbc.Query.*;
import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL;
@@ -130,6 +131,29 @@ public class SqliteDb extends BaseDb implements AccountDb {
}
}
@Override
public Optional<Transaction> lastTransaction(long accountId, String source, String dest, double amount) {
try {
var rs = select(ALL).from(TABLE_TRANSACTIONS)
.where(ACCOUNT,equal(accountId)).where(SOURCE,equal(source)).where(DESTINATION,equal(dest)).where(AMOUNT,equal(amount))
.sort(ID+" DESC")
.limit(1)
.exec(db);
Transaction ta = null;
if (rs.next()) ta = Transaction.of(rs);
rs.close();
if (ta != null){
var tags = ta.tags();
rs = select(TAG).from(TABLE_TAGS_TRANSACTIONS).leftJoin(TAG_ID,TABLE_TAGS,ID).where(TRANSACTION_ID,equal(ta.id())).exec(db);
while (rs.next()) tags.add(rs.getString(1));
rs.close();
}
return nullable(ta);
} catch (SQLException e) {
throw failedToSearchDb(t(Text.ACCOUNTING));
}
}
@Override
public HashSet<Account> listAccounts(long userId) {
try {
@@ -150,6 +174,45 @@ public class SqliteDb extends BaseDb implements AccountDb {
}
}
@Override
public Collection<String> listTags(long accountId, String source, String destination) {
try {
var rs = select(TRANSACTION_ID,Field.TAG)
.from(TABLE_TRANSACTIONS)
.leftJoin(ID,TABLE_TAGS_TRANSACTIONS, TRANSACTION_ID)
.leftJoin(TAG_ID,TABLE_TAGS,ID)
.where(ACCOUNT,equal(accountId))
.where(SOURCE,equal(source))
.where(DESTINATION,equal(destination))
.sort(TRANSACTION_ID).exec(db);
Set<String> set = null;
Set<String> transactionTags = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
Long lastTransaction = null;
while (rs.next()){
var currentTransaction = rs.getLong(TRANSACTION_ID);
if (lastTransaction == null) { // first row
transactionTags.add(rs.getString(TAG));
lastTransaction = currentTransaction;
} else if (lastTransaction == currentTransaction) {
transactionTags.add(rs.getString(TAG));
} else {
if (set == null) {
set = transactionTags;
} else {
set.retainAll(transactionTags);
}
transactionTags = new HashSet<>();
transactionTags.add(rs.getString(TAG));
lastTransaction = currentTransaction;
}
}
rs.close();
return set == null ? transactionTags : set;
} catch (SQLException e){
throw failedToLoadMembers(accountId);
}
}
@Override
public Account loadAccount(long accountId) {
try {
@@ -232,7 +295,7 @@ public class SqliteDb extends BaseDb implements AccountDb {
}
} else if (transaction.isDirty()) {
try {
if (transaction.amount() == 0) {
if (transaction.amount() == 0 || transaction.source().isEmpty() || transaction.destination().isEmpty()) {
delete().from(TABLE_TRANSACTIONS).where(Field.ID, equal(transaction.id())).where(ACCOUNT, equal(transaction.accountId())).execute(db);
} else {
replaceInto(TABLE_TRANSACTIONS, Field.ID, Field.ACCOUNT, Field.TIMESTAMP, Field.SOURCE, Field.DESTINATION, Field.AMOUNT, Field.DESCRIPTION)
@@ -6,6 +6,9 @@ import de.srsoftware.umbrella.core.constants.Field;
import java.util.HashMap;
import java.util.Map;
import static de.srsoftware.tools.Optionals.isSet;
import static de.srsoftware.tools.Optionals.nullIfEmpty;
public class IdOrString implements Mappable {
private final Long id;
private final String value;
@@ -52,6 +55,10 @@ public class IdOrString implements Mappable {
return map;
}
public boolean isEmpty() {
return id == null && !isSet(value);
}
@Override
public String toString() {
return value;
@@ -60,4 +67,4 @@ public class IdOrString implements Mappable {
public String value(){
return value;
}
}
}
+58 -22
View File
@@ -14,8 +14,10 @@
const ignore = ['ArrowLeft','ArrowRight'];
//let candidate = $state({ display : '' });
let selected = $state([]);
let candidates = $derived(getCandidates(candidate.display));
let selected = $state(null);
let candidates = $state([]);
let timer = null;
let list_elem;
async function dummyGetCandidates(text){
console.warn(`getCandidates(${text}) not overridden!`);
@@ -52,36 +54,43 @@
const idx = select.value;
candidate = candidates[idx];
candidates = [];
selected = [];
selected = null;
onSelect(candidate);
}
async function fetchCandidates(){
candidates = candidate.display ? await getCandidates(candidate.display) : [];
selected = null;
}
async function onkeyup(ev){
if (ignore.includes(ev.key)) return;
if (ev.key == 'ArrowDown'){
ev.preventDefault();
selected = selected.length < 1 ? [0] : [selected[0]+1]
if (selected[0] >= candidates.length) selected = [0];
selected = selected == null ? 0: selected +1;
if (selected >= candidates.length) selected = 0;
scrollTo(selected);
return false;
}
if (ev.key == 'ArrowUp'){
ev.preventDefault();
selected = selected.length < 1 ? [-1] : [selected[0]-1]
if (selected[0] < 0) selected = [candidates.length-1];
selected = selected == null ? candidates.length -1 : selected -1;
if (selected < 0) selected = candidates.length -1;
scrollTo(selected);
return false;
}
if (ev.key == 'Enter'|| ev.key == 'Tab'){
ev.preventDefault();
if (selected.length>0) {
candidate = candidates[selected[0]];
if (selected != null && selected < candidates.length) {
candidate = candidates[selected];
candidates = [];
selected = [];
selected = null;
onSelect(candidate);
return false;
}
if (ev.key == 'Enter') {
candidates = [];
selected = [];
selected = null;
if (onCommit(candidate)) candidate = { display : '' };
}
return false;
@@ -89,32 +98,59 @@
if (ev.key == 'Escape'){
ev.preventDefault();
candidates = [];
selected = [];
selected = null;
return false;
}
candidate = { display : candidate.display };
candidates = await getCandidates(candidate.display);
if (selected>candidates.length) selected = candidates.length;
if (timer) clearTimeout(timer);
timer = setTimeout(fetchCandidates,400);
return false;
}
function select(index){
candidate = candidates[index];
selected = null;
candidates = [];
onSelect(candidate);
}
function scrollTo(index){
let list_elements = list_elem.children;
if (list_elements) list_elements[index].scrollIntoView({block:'center'});
}
</script>
<style>
span { position : relative }
select { position : absolute; top: 30px; left: 3px; }
ul {
position : absolute;
top: 30px;
left: 3px;
background: black;
color: orange;
border: 1px solid orange;
border-radius: 5px;
z-index: 50;
list-style: none;
padding: 4px;
margin: 0;
min-width: 400px;
max-height: 200px;
overflow: scroll;
}
.highlight { background: orange; color: black; }
select { background: black; color: orange; border: 1px solid orange; border-radius: 5px; z-index: 50; }
option:checked { background: orange; color: black; }
</style>
<span>
<span class="autocomplete">
<input type="text" bind:value={candidate.display} {onkeyup} autofocus={autofocus} {id} />
{#if candidates && candidates.length > 0}
<select bind:value={selected} {ondblclick} multiple tabindex="-1">
<ul bind:this={list_elem} class="suggestions">
{#each candidates as candidate,i}
<option value={i}>{candidate.display}</option>
<li class="option {selected==i?'highlight':''}" onclick={e => select(i)} ondblclick={e => select(i)}>{candidate.display}</li>
{/each}
</select>
</ul>
{/if}
</span>
</span>
@@ -155,5 +155,8 @@
{/if}
{:else}
<Display classes={{editable}} markdown={value} {onclick} {oncontextmenu} title={t('right_click_to_edit')} wrapper={type} />
{#if !value.display}
<button onclick={oncontextmenu}>{t('add_object',{object:t('content')})}</button>
{/if}
{/if}
</div>
@@ -27,6 +27,7 @@
if (!transaction.destination.id) sums[0] += transaction.amount;
if (!transaction.source.id) sums[0] -= transaction.amount;
}
window.setTimeout(scrollToBottom,100);
return sums;
}
@@ -65,6 +66,10 @@
load();
}
function scrollToBottom(){
window.scrollTo(0, document.body.scrollHeight);
}
onMount(load);
</script>
@@ -72,6 +77,12 @@
.amount{ text-align: right }
</style>
<svelte:head>
{#if account}
<title>Umbrella {account.name}</title>
{/if}
</svelte:head>
{#if filter.length > 0}
<fieldset>
<legend>{t('filter by tags')}</legend>
+59 -21
View File
@@ -29,8 +29,38 @@
});
let router = useTinyRouter();
async function getTerminal(text,url){
var res = await post(url,text);
async function dst_selected(destination){
destination = JSON.parse(JSON.stringify(destination));
let source = JSON.parse(JSON.stringify(entry.source));
const url = api(`accounting/${entry.account.id}/tags`)
const res = await post(url,{source,destination});
if (res.ok) {
yikes();
const json = await res.json();
entry.tags = json;
} else error(res);
}
function focusOnEnter(ev,id){
if (ev.key == 'Enter') {
proposePurpose();
document.getElementById(id).focus();
}
}
async function getAccountTags(text){
if (!text) return [];
const url = api(`accounting/${entry.account.id}/tags`)
return await getProposals(text,url);
}
async function getDestinations(text){
const url = api('accounting/destinations');
return await getProposals(text,url);
}
async function getProposals(text,url){
const res = await post(url,text);
if (res.ok){
yikes();
const input = await res.json();
@@ -41,25 +71,19 @@
}
}
async function getAccountTags(text){
if (!text) return [];
var url = api(`accounting/${entry.account.id}/tags`)
return await getTerminal(text,url);
}
async function getDestinations(text){
var url = api('accounting/destinations');
return await getTerminal(text,url);
}
async function getPurposes(text) {
var url = api('accounting/purposes');
return await getTerminal(text,url);
const url = api('accounting/purposes');
return await getProposals(text,url);
}
async function getSources(text){
var url = api('accounting/sources');
return await getTerminal(text,url);
const url = api('accounting/sources');
return await getProposals(text,url);
}
function gotoTags(purpose){
document.getElementById('new_tag_input');
}
function mapDisplay(object){
@@ -72,6 +96,20 @@
}
}
async function proposePurpose(){
const source = entry.source;
const destination = entry.destination;
const amount = entry.amount;
const url = api(`accounting/${account.id}/purposes`);
const res = await post(url,{source,destination,amount});
if (res.ok) {
yikes();
var lastTransaction = await res.json();
entry.purpose = { display: lastTransaction.purpose};
entry.tags = lastTransaction.tags;
} else error(res);
}
async function save(){
let data = {
...entry,
@@ -85,7 +123,7 @@
router.navigate('/accounting');
return;
}
entry.tags = [];
//entry.tags = [];
onSave();
document.getElementById('date-input').focus();
} else error(res);
@@ -131,15 +169,15 @@
<Autocomplete bind:candidate={entry.source} getCandidates={getSources} id="source-input" />
<span>{t('destination')}</span>
<Autocomplete bind:candidate={entry.destination} getCandidates={getDestinations} />
<Autocomplete bind:candidate={entry.destination} getCandidates={getDestinations} onSelect={dst_selected} />
<span>{t('amount')}</span>
<span>
<input type="number" bind:value={entry.amount} />&nbsp;{entry.account.currency}
<input type="number" bind:value={entry.amount} onkeyup={e => focusOnEnter(e,'purpose_input')} />&nbsp;{entry.account.currency}
</span>
<span>{t('purpose')}</span>
<Autocomplete bind:candidate={entry.purpose} getCandidates={getPurposes} />
<Autocomplete bind:candidate={entry.purpose} getCandidates={getPurposes} onCommit={gotoTags} id="purpose_input" />
<span>{t('tags')}</span>
<Tags getCandidates={getAccountTags} module={null} bind:tags={entry.tags} onEmptyCommit={save} />
@@ -148,4 +186,4 @@
<span>
<button onclick={save}>{t('save')}</button>
</span>
</fieldset>
</fieldset>
@@ -32,6 +32,10 @@
onMount(load);
</script>
<svelte:head>
<title>Umbrella {t('accounts')}</title>
</svelte:head>
<fieldset>
<span></span>
+2 -1
View File
@@ -200,9 +200,10 @@
<LineEditor bind:value={project.name} editable={true} onSet={val => update({name:val})} />
</div>
<div>
<button onclick={kanban}>{t('show_kanban')}</button>
{t('options')}
</div>
<div>
<button onclick={kanban}><span class="symbol"></span> {t('show_kanban')}</button>
<button onclick={toggleSettings}><span class="symbol"></span> {t('settings')}</button>
</div>
<div>{t('state')}</div>
+1 -1
View File
@@ -112,6 +112,6 @@
</span>
{/each}
<span class="tag editor">
<Autocomplete {getCandidates} {onCommit} {onSelect} />
<Autocomplete {getCandidates} {onCommit} {onSelect} id="new_tag_input" />
</span>
</div>
@@ -13,6 +13,8 @@ import de.srsoftware.umbrella.core.BaseDb;
import de.srsoftware.umbrella.messagebus.events.Event;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
public class SqliteDb extends BaseDb implements JournalDb{
public SqliteDb(Connection connection) {
@@ -33,13 +35,14 @@ public class SqliteDb extends BaseDb implements JournalDb{
var sql = """
CREATE TABLE IF NOT EXISTS {0} (
{1} INTEGER PRIMARY KEY,
{2} INTEGER,
{3} VARCHAR(255) NOT NULL,
{4} VARCHAR(16) NOT NULL,
{5} TEXT
{2} LONG NOT NULL,
{3} INTEGER,
{4} VARCHAR(255) NOT NULL,
{5} VARCHAR(16) NOT NULL,
{6} TEXT
);
""";
sql = format(sql,TABLE_JOURNAL,ID,USER_ID,MODULE,ACTION,DESCRIPTION);
sql = format(sql,TABLE_JOURNAL,ID,TIMESTAMP,USER_ID,MODULE,ACTION,DESCRIPTION);
try {
db.prepareStatement(sql).execute();
} catch (SQLException e) {
@@ -50,8 +53,9 @@ public class SqliteDb extends BaseDb implements JournalDb{
@Override
public void logEvent(Event<?> event) {
try {
insertInto(TABLE_JOURNAL,USER_ID,MODULE,ACTION,DESCRIPTION)
.values(event.initiator().id(), event.module(), event.eventType(), event.describe())
var timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
insertInto(TABLE_JOURNAL,TIMESTAMP,USER_ID,MODULE,ACTION,DESCRIPTION)
.values(timestamp,event.initiator().id(), event.module(), event.eventType(), event.describe())
.execute(db).close();
} catch (SQLException e) {
throw databaseException(ERROR_WRITE_EVENT,event.eventType(),event.initiator().name());
+3
View File
@@ -192,6 +192,8 @@
"items": "Artikel",
"join_objects" : "{objects} zusammenführen",
"kanban": "Kanban",
"key": "Suchbegriff",
"language": "Sprache",
@@ -307,6 +309,7 @@
"project ({id})": "Projekt ({id})",
"Project '{project}' was edited": "Projekt '{project}' wurde bearbeitet",
"projects": "Projekte",
"Projects": "Projekte",
"properties": "Eigenschaften",
"property": "Eigenschaft",
"purpose": "Zweck",
+3
View File
@@ -192,6 +192,8 @@
"items": "items",
"join_objects" : "join {objects}",
"kanban": "Kanban",
"key": "search term",
"language": "language",
@@ -307,6 +309,7 @@
"project ({id})": "project ({id})",
"Project '{project}' was edited": "Project '{project}' was edited",
"projects": "projects",
"Projects": "projects",
"properties": "properties",
"property": "property",
"purpose": "purpose",
+15 -2
View File
@@ -275,7 +275,7 @@ textarea{
.account .sums{
position: sticky;
bottom: 0;
z-index: 100;
z-index: 60;
}
.taglist .editor > span{
@@ -426,6 +426,10 @@ a.wikilink{
display: block;
}
select.autocomplete{
z-index: 100;
}
.grid2{
display: grid;
grid-template-columns: 230px auto;
@@ -442,6 +446,10 @@ a.wikilink{
grid-template-columns: [left] 1fr [first] 1fr [second] 1fr [right]
}
.stock.grid3{
grid-template-columns: [left] 1fr [first] 1fr [second] 2fr [right]
}
.grid3 .locations {
grid-row-end: span 3;
}
@@ -534,7 +542,8 @@ a.wikilink{
}
@media screen and (max-width: 600px) {
.grid2{
.grid2,
.grid3{
display: grid;
grid-template-columns: auto;
}
@@ -574,6 +583,10 @@ a.wikilink{
#app nav.expanded .timetracking{
grid-column-end: span 2;
}
.autocomplete .suggestions > *{
font-size: 1.5em;
}
}
fieldset.vcard{
+9 -1
View File
@@ -368,7 +368,7 @@ textarea{
.account .sums{
position: sticky;
bottom: 0;
z-index: 100;
z-index: 60;
}
.taglist .editor > span{
@@ -529,6 +529,10 @@ a.wikilink{
display: block;
}
select.autocomplete{
z-index: 100;
}
.grid2{
display: grid;
grid-template-columns: 230px auto;
@@ -707,6 +711,10 @@ a.wikilink{
#app nav.expanded .timetracking{
grid-column-end: span 2;
}
.autocomplete .suggestions > *{
font-size: 1.5em;
}
}
fieldset.vcard{
+9 -1
View File
@@ -368,7 +368,7 @@ textarea{
.account .sums{
position: sticky;
bottom: 0;
z-index: 100;
z-index: 60;
}
.taglist .editor > span{
@@ -519,6 +519,10 @@ a.wikilink{
display: block;
}
select.autocomplete{
z-index: 100;
}
.grid2{
display: grid;
grid-template-columns: 230px auto;
@@ -697,6 +701,10 @@ a.wikilink{
#app nav.expanded .timetracking{
grid-column-end: span 2;
}
.autocomplete .suggestions > *{
font-size: 1.5em;
}
}
fieldset.vcard{