summaryrefslogtreecommitdiff
path: root/clients/bokftui.c
diff options
context:
space:
mode:
Diffstat (limited to 'clients/bokftui.c')
-rw-r--r--clients/bokftui.c4076
1 files changed, 4076 insertions, 0 deletions
diff --git a/clients/bokftui.c b/clients/bokftui.c
new file mode 100644
index 0000000..1939eeb
--- /dev/null
+++ b/clients/bokftui.c
@@ -0,0 +1,4076 @@
+#include <dirent.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+
+/* universal "new/add" hotkey: Ctrl+N (0x0e), the de-facto Linux convention */
+#define KEY_CTRL_N 0x0e
+/* Ctrl+C quits the application; Esc is navigation only */
+static int g_quit = 0;
+
+static int ui_getch(void)
+{
+ int ch = getch();
+ if (ch == 3) { /* Ctrl+C */
+ g_quit = 1;
+ return 27; /* behave like "back" so screens unwind */
+ }
+ return ch;
+}
+
+struct app {
+ int fd;
+ char socket[256];
+ char session[128];
+ char username[64];
+ int64_t org;
+ char org_name[128];
+ char role[32];
+ int64_t fy;
+ int64_t voucher_sel; /* last selected voucher id in the list view */
+ char default_series[16];
+ char attachment_dir[256];
+ long max_attachment_bytes;
+ char fy_label[64];
+ char fy_start[16];
+ char fy_end[16];
+};
+
+/* ------------------------------------------------------------------ */
+/* json helpers */
+/* ------------------------------------------------------------------ */
+
+static yyjson_val *jget(yyjson_val *root, const char *path)
+{
+ char tmp[128];
+ snprintf(tmp, sizeof tmp, "%s", path);
+ yyjson_val *v = root;
+ char *save = NULL;
+ for (char *tok = strtok_r(tmp, ".", &save); tok;
+ tok = strtok_r(NULL, ".", &save)) {
+ if (!v)
+ return NULL;
+ if (yyjson_is_obj(v))
+ v = yyjson_obj_get(v, tok);
+ else if (yyjson_is_arr(v)) {
+ char *end = NULL;
+ long idx = strtol(tok, &end, 10);
+ if (!end || *end)
+ return NULL;
+ v = yyjson_arr_get(v, (size_t)idx);
+ } else {
+ return NULL;
+ }
+ }
+ return v;
+}
+
+static yyjson_doc *parse(const char *resp)
+{
+ return resp ? yyjson_read(resp, strlen(resp), 0) : NULL;
+}
+
+static char *jstr_dup(const char *resp, const char *path)
+{
+ yyjson_doc *d = parse(resp);
+ yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
+ char *out = v && yyjson_is_str(v) ? xstrdup(yyjson_get_str(v)) : NULL;
+ yyjson_doc_free(d);
+ return out;
+}
+
+static int64_t jint_val(const char *resp, const char *path, int64_t def)
+{
+ yyjson_doc *d = parse(resp);
+ yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
+ int64_t out = v && yyjson_is_int(v) ? yyjson_get_int(v) : def;
+ yyjson_doc_free(d);
+ return out;
+}
+
+static size_t jarr_size(const char *resp, const char *path)
+{
+ yyjson_doc *d = parse(resp);
+ yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
+ size_t n = v && yyjson_is_arr(v) ? yyjson_arr_size(v) : 0;
+ yyjson_doc_free(d);
+ return n;
+}
+
+static int jbool_val(const char *resp, const char *path, int def)
+{
+ yyjson_doc *d = parse(resp);
+ yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
+ int out = v && yyjson_is_bool(v) ? yyjson_get_bool(v) : def;
+ yyjson_doc_free(d);
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* ui primitives */
+/* ------------------------------------------------------------------ */
+
+static char g_status[512];
+
+static void show_error(const char *title, const char *resp);
+static int64_t vouchers_new(struct app *a);
+static void acct_cache_free(void);
+static void acct_cache_load(struct app *a);
+static const char *acct_name(struct app *a, const char *number);
+static int acct_exists(struct app *a, const char *number);
+
+/* ---- shared line editor used by every form and prompt ---- */
+struct ledit {
+ char *buf;
+ size_t cap;
+ size_t len;
+ size_t pos; /* caret position in bytes */
+};
+
+static size_t disp_width_n(const char *s, size_t n)
+{
+ size_t w = 0;
+ for (size_t i = 0; i < n && s[i]; i++)
+ if (((unsigned char)s[i] & 0xC0) != 0x80)
+ w++;
+ return w;
+}
+
+static void le_init(struct ledit *e, char *buf, size_t cap)
+{
+ e->buf = buf;
+ e->cap = cap;
+ e->len = strlen(buf);
+ e->pos = e->len;
+}
+
+static void le_clear(struct ledit *e)
+{
+ e->len = 0;
+ e->pos = 0;
+ e->buf[0] = '\0';
+}
+
+static void le_insert(struct ledit *e, unsigned char ch)
+{
+ if (e->len + 1 >= e->cap)
+ return;
+ memmove(e->buf + e->pos + 1, e->buf + e->pos, e->len - e->pos + 1);
+ e->buf[e->pos] = (char)ch;
+ e->pos++;
+ e->len++;
+}
+
+static void le_backspace(struct ledit *e)
+{
+ if (e->pos == 0)
+ return;
+ memmove(e->buf + e->pos - 1, e->buf + e->pos, e->len - e->pos + 1);
+ e->pos--;
+ e->len--;
+}
+
+static void le_delete(struct ledit *e)
+{
+ if (e->pos >= e->len)
+ return;
+ memmove(e->buf + e->pos, e->buf + e->pos + 1, e->len - e->pos);
+ e->len--;
+}
+
+/* Consumes the key when it is an editing key; returns 1 then. */
+static int le_key(struct ledit *e, int ch)
+{
+ if (ch == KEY_LEFT) {
+ if (e->pos > 0) {
+ e->pos--;
+ while (e->pos > 0 && ((unsigned char)e->buf[e->pos] & 0xC0) == 0x80)
+ e->pos--;
+ }
+ return 1;
+ }
+ if (ch == KEY_RIGHT) {
+ if (e->pos < e->len) {
+ e->pos++;
+ while (e->pos < e->len &&
+ ((unsigned char)e->buf[e->pos] & 0xC0) == 0x80)
+ e->pos++;
+ }
+ return 1;
+ }
+ if (ch == KEY_HOME || ch == 1) { /* Ctrl+A */
+ e->pos = 0;
+ return 1;
+ }
+ if (ch == KEY_END || ch == 5) { /* Ctrl+E */
+ e->pos = e->len;
+ return 1;
+ }
+ if (ch == KEY_DC) {
+ le_delete(e);
+ return 1;
+ }
+ if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
+ le_backspace(e);
+ return 1;
+ }
+ if (ch == 21) { /* Ctrl+U */
+ le_clear(e);
+ return 1;
+ }
+ if (ch >= 32 && ch < 256) {
+ le_insert(e, (unsigned char)ch);
+ return 1;
+ }
+ return 0;
+}
+
+/* Date field: only digits are accepted, dashes are inserted automatically
+ ("20260215" -> "2026-02-15"). The caret is tracked in digit space and
+ reported back as a byte offset so forms render it like any field. */
+static int date_field_edit(char *buf, size_t cap, size_t *pos, int ch,
+ int *fresh)
+{
+ char d[9];
+ size_t n = 0;
+ for (const char *q = buf; *q && n < 8; q++)
+ if (*q >= '0' && *q <= '9')
+ d[n++] = *q;
+ size_t ci = n; /* caret in digit space; byte pos -> digit index */
+ if (*pos != (size_t)-1) {
+ size_t seen = 0;
+ for (size_t i = 0; i < *pos && buf[i]; i++)
+ if (buf[i] >= '0' && buf[i] <= '9')
+ seen++;
+ ci = seen;
+ }
+
+ if (*fresh) {
+ int editing = ch == KEY_BACKSPACE || ch == 127 || ch == 8 ||
+ ch == KEY_DC || (ch >= '0' && ch <= '9');
+ int moving = ch == KEY_LEFT || ch == KEY_RIGHT || ch == KEY_HOME ||
+ ch == KEY_END || ch == 1 || ch == 5;
+ if (editing || moving) {
+ if (editing) {
+ n = 0;
+ ci = 0;
+ }
+ *fresh = 0; /* movement keeps the date and moves the caret */
+ }
+ }
+
+ if (ch >= '0' && ch <= '9') {
+ if (ci > n)
+ ci = n;
+ if (n < 8) {
+ for (size_t i = n; i > ci; i--)
+ d[i] = d[i - 1];
+ d[ci] = (char)ch;
+ n++;
+ } else {
+ /* full field: overwrite the digit at the caret */
+ if (ci > 7)
+ ci = 7;
+ d[ci] = (char)ch;
+ }
+ ci++;
+ } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
+ if (ci > 0) {
+ for (size_t i = ci - 1; i + 1 < n; i++)
+ d[i] = d[i + 1];
+ n--;
+ ci--;
+ }
+ } else if (ch == KEY_DC) {
+ if (ci < n) {
+ for (size_t i = ci; i + 1 < n; i++)
+ d[i] = d[i + 1];
+ n--;
+ }
+ } else if (ch == KEY_LEFT) {
+ if (ci > 0)
+ ci--;
+ } else if (ch == KEY_RIGHT) {
+ if (ci < n)
+ ci++;
+ } else if (ch == KEY_HOME || ch == 1) {
+ ci = 0;
+ } else if (ch == KEY_END || ch == 5) {
+ ci = n;
+ } else if (ch == 21) {
+ n = 0;
+ ci = 0;
+ *fresh = 0;
+ } else {
+ return 0; /* not a date editing key */
+ }
+
+ /* rebuild "YYYY-MM-DD" (partial while typing) */
+ char out[16];
+ size_t o = 0;
+ for (size_t i = 0; i < n; i++) {
+ if (i == 4 || i == 6)
+ out[o++] = '-';
+ out[o++] = d[i];
+ }
+ out[o] = '\0';
+ snprintf(buf, cap, "%s", out);
+
+ size_t byte = ci;
+ if (n > 4 && ci >= 4)
+ byte++;
+ if (n > 6 && ci >= 6)
+ byte++;
+ if (byte > strlen(buf))
+ byte = strlen(buf);
+ *pos = byte;
+ return 1;
+}
+
+/* Prompt for a date with the date editor. Returns a static buffer or NULL
+ on Esc. */
+static char *date_prompt(const char *label, const char *def)
+{
+ static char buf[16];
+ snprintf(buf, sizeof buf, "%s", def ? def : "");
+ int y = LINES - 3;
+ attron(A_BOLD);
+ mvaddstr(y, 2, label);
+ attroff(A_BOLD);
+ size_t pos = (size_t)-1;
+ int fresh = 1;
+ curs_set(1);
+ for (;;) {
+ move(y, (int)strlen(label) + 3);
+ clrtoeol();
+ addnstr(buf, (int)sizeof buf);
+ clrtoeol();
+ size_t slen = strlen(buf);
+ size_t cp = pos == (size_t)-1 || pos > slen ? slen : pos;
+ move(y, (int)strlen(label) + 3 + (int)disp_width_n(buf, cp));
+ refresh();
+ int ch = ui_getch();
+ if (ch == '\n' || ch == '\r' || ch == KEY_ENTER)
+ return buf;
+ if (ch == 27)
+ return NULL;
+ date_field_edit(buf, sizeof buf, &pos, ch, &fresh);
+ }
+}
+
+/* Applies a key to a form field. *pos is the caret, (size_t)-1 = at the
+ end. When *fresh is set, the first editing key replaces the content. */
+static int field_edit(char *buf, size_t cap, size_t *pos, int ch, int *fresh)
+{
+ struct ledit e;
+ le_init(&e, buf, cap);
+ if (*pos != (size_t)-1 && *pos <= e.len)
+ e.pos = *pos;
+ if (*fresh) {
+ int editing = ch == KEY_BACKSPACE || ch == 127 || ch == 8 ||
+ ch == KEY_DC || (ch >= 32 && ch < 256);
+ int moving = ch == KEY_LEFT || ch == KEY_RIGHT || ch == KEY_HOME ||
+ ch == KEY_END || ch == 1 || ch == 5;
+ if (editing || moving) {
+ if (editing)
+ le_clear(&e);
+ *fresh = 0; /* movement keeps the content and moves the caret */
+ }
+ }
+ int handled = le_key(&e, ch);
+ *pos = e.pos;
+ return handled;
+}
+
+static void frame(const char *title)
+{
+ erase();
+ attron(A_BOLD);
+ box(stdscr, 0, 0);
+ mvaddstr(0, 2, title);
+ attroff(A_BOLD);
+ if (g_status[0]) {
+ attron(A_DIM);
+ mvaddnstr(1, 2, g_status, COLS - 4);
+ attroff(A_DIM);
+ }
+}
+
+static void hints(const char *s)
+{
+ attron(A_DIM);
+ mvaddstr(LINES - 1, 2, s);
+ attroff(A_DIM);
+ clrtoeol();
+}
+
+static void message(const char *title, const char *fmt, ...)
+ __attribute__((format(printf, 2, 3)));
+
+static void message(const char *title, const char *fmt, ...)
+{
+ char text[1024];
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(text, sizeof text, fmt, ap);
+ va_end(ap);
+ int h = 5, w = (int)strlen(text) + 4;
+ if (w > COLS - 4)
+ w = COLS - 4;
+ if (w < 30)
+ w = 30;
+ WINDOW *win = newwin(h, w, (LINES - h) / 2, (COLS - w) / 2);
+ box(win, 0, 0);
+ wattron(win, A_BOLD);
+ mvwaddnstr(win, 0, 2, title, w - 4);
+ wattroff(win, A_BOLD);
+ mvwaddnstr(win, 2, 2, text, w - 4);
+ mvwaddstr(win, h - 1, w - 12, " tryck Enter");
+ wrefresh(win);
+ nodelay(stdscr, FALSE);
+ timeout(-1);
+ for (;;) {
+ int ch = wgetch(win);
+ if (ch == 3) {
+ g_quit = 1;
+ break;
+ }
+ if (ch == '\n' || ch == '\r' || ch == ' ' || ch == 27 || ch == KEY_ENTER)
+ break;
+ }
+ delwin(win);
+ touchwin(stdscr);
+ refresh();
+}
+
+/* Line editor inside the current window at (y,x) with fixed label already
+ drawn. Returns 1 on Enter, 2 on Tab, 3 on Shift-Tab, 0 on Esc. */
+static int edit_field(int y, int x, char *buf, size_t cap, int mask)
+{
+ size_t pos = (size_t)-1;
+ int fresh = 1;
+ for (;;) {
+ move(y, x);
+ if (mask) {
+ size_t len = strlen(buf);
+ for (size_t i = 0; i < len; i++)
+ addch('*');
+ } else {
+ addnstr(buf, (int)cap);
+ }
+ clrtoeol();
+ size_t len = strlen(buf);
+ size_t cp = pos == (size_t)-1 || pos > len ? len : pos;
+ move(y, x + (mask ? (int)cp : (int)disp_width_n(buf, cp)));
+ refresh();
+ int ch = ui_getch();
+ if (ch == '\n' || ch == '\r' || ch == KEY_ENTER)
+ return 1;
+ if (ch == 27)
+ return 0;
+ if (ch == '\t')
+ return 2;
+ if (ch == KEY_BTAB)
+ return 3;
+ field_edit(buf, cap, &pos, ch, &fresh);
+ }
+}
+
+static char *prompt(const char *label, const char *def, int mask)
+{
+ static char buf[512];
+ snprintf(buf, sizeof buf, "%s", def ? def : "");
+ int y = LINES - 3;
+ attron(A_BOLD);
+ mvaddstr(y, 2, label);
+ attroff(A_BOLD);
+ move(y, (int)strlen(label) + 3);
+ clrtoeol();
+ refresh();
+ int prev = curs_set(1);
+ int rc = edit_field(y, (int)strlen(label) + 3, buf, sizeof buf, mask);
+ curs_set(prev == ERR ? 0 : prev);
+ if (rc == 0)
+ return NULL;
+ return buf;
+}
+
+static int text_view(const char *title, const char *text)
+{
+ int ret = 0;
+ int nlines = 0;
+ for (const char *p = text; *p; p++)
+ if (*p == '\n')
+ nlines++;
+ nlines += 1;
+ char **lines = xcalloc(nlines ? nlines : 1, sizeof(char *));
+ int n = 0;
+ char *copy = xstrdup(text);
+ for (char *p = copy;;) {
+ char *nl = strchr(p, '\n');
+ if (nl)
+ *nl = '\0';
+ lines[n++] = p;
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ int top = 0;
+ int view = LINES - 4;
+ for (;;) {
+ frame(title);
+ for (int i = 0; i < view && top + i < n; i++)
+ mvaddnstr(2 + i, 2, lines[top + i], COLS - 4);
+ hints("piltangenter/PgUp/PgDn rullar F5 = uppdatera q = tillbaka");
+ refresh();
+ int ch = ui_getch();
+ if (ch == 'q' || ch == 27)
+ break;
+ if (ch == KEY_F(5)) {
+ ret = 1;
+ break;
+ }
+ if (ch == KEY_DOWN && top + view < n)
+ top++;
+ else if (ch == KEY_UP && top > 0)
+ top--;
+ else if (ch == KEY_NPAGE)
+ top += view;
+ else if (ch == KEY_PPAGE)
+ top -= view;
+ else if (ch == KEY_HOME)
+ top = 0;
+ else if (ch == KEY_END)
+ top = n > view ? n - view : 0;
+ if (top + view > n)
+ top = n > view ? n - view : 0;
+ if (top < 0)
+ top = 0;
+ }
+ free(copy);
+ free(lines);
+ return ret;
+}
+
+static int menu(const char *title, const char *const *items, int n,
+ int allow_new)
+{
+ int sel = 0;
+ int top = 0;
+ char gotobuf[12] = "";
+ int goto_active = 0;
+ for (;;) {
+ int view = (LINES - 4) / 2;
+ if (view < 1)
+ view = 1;
+ if (sel < top)
+ top = sel;
+ if (sel >= top + view)
+ top = sel - view + 1;
+ frame(title);
+ for (int i = 0; i < view && top + i < n; i++) {
+ int idx = top + i;
+ char line[512];
+ snprintf(line, sizeof line, "%2d. %s", idx + 1, items[idx]);
+ if (idx == sel)
+ attron(A_REVERSE);
+ mvaddnstr(3 + i * 2, 4, line, COLS - 6);
+ if (idx == sel)
+ attroff(A_REVERSE);
+ }
+ if (goto_active) {
+ move(LINES - 2, 2);
+ attron(A_BOLD);
+ printw("Gå till nummer: %s", gotobuf);
+ attroff(A_BOLD);
+ clrtoeol();
+ } else {
+ move(LINES - 2, 2);
+ clrtoeol();
+ }
+ hints(allow_new
+ ? "upp/ned, 1-9 = snabbval, g = gå till, PgUp/PgDn,"
+ " Home/End, Enter Ctrl+N = ny Esc/q = tillbaka"
+ " Ctrl+C = avsluta"
+ : "upp/ned, 1-9 = snabbval, g = gå till, PgUp/PgDn,"
+ " Home/End, Enter Esc/q = tillbaka Ctrl+C = avsluta");
+ refresh();
+ int ch = ui_getch();
+ if (goto_active) {
+ if (ch >= '0' && ch <= '9' && strlen(gotobuf) < 9) {
+ size_t gl = strlen(gotobuf);
+ gotobuf[gl] = (char)ch;
+ gotobuf[gl + 1] = '\0';
+ int v = atoi(gotobuf);
+ if (v >= 1 && v <= n)
+ sel = v - 1;
+ } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
+ size_t gl = strlen(gotobuf);
+ if (gl)
+ gotobuf[gl - 1] = '\0';
+ if (gotobuf[0]) {
+ int v = atoi(gotobuf);
+ if (v >= 1 && v <= n)
+ sel = v - 1;
+ }
+ } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER ||
+ ch == 27 || ch == 'g' || ch == 'G') {
+ goto_active = 0;
+ gotobuf[0] = '\0';
+ }
+ continue;
+ }
+ if (ch == KEY_UP && sel > 0)
+ sel--;
+ else if (ch == KEY_DOWN && sel < n - 1)
+ sel++;
+ else if (ch == KEY_NPAGE)
+ sel = sel + view < n ? sel + view : n - 1;
+ else if (ch == KEY_PPAGE)
+ sel = sel - view > 0 ? sel - view : 0;
+ else if (ch == KEY_HOME)
+ sel = 0;
+ else if (ch == KEY_END)
+ sel = n - 1;
+ else if (ch >= '1' && ch <= '9' && ch - '1' < n)
+ return ch - '1';
+ else if (ch == 'g' || ch == 'G') {
+ goto_active = 1;
+ gotobuf[0] = '\0';
+ } else if (ch == KEY_CTRL_N && allow_new)
+ return -4;
+ else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER)
+ return sel;
+ else if (ch == 27 || ch == 'q')
+ return g_quit ? -5 : -1;
+ }
+}
+
+static int select_list(const char *title, char **items, int n, int start,
+ int allow_refresh, int *cursor, int allow_new)
+{
+ int sel = start;
+ int top = 0;
+ int view = LINES - 4;
+ char gotobuf[12] = "";
+ int goto_active = 0;
+ for (;;) {
+ if (cursor)
+ *cursor = sel;
+ frame(title);
+ if (sel < top)
+ top = sel;
+ if (sel >= top + view)
+ top = sel - view + 1;
+ int numw = 1;
+ for (int tmp = n; tmp >= 10; tmp /= 10)
+ numw++;
+ if (numw > 9)
+ numw = 9;
+ for (int i = 0; i < view && top + i < n; i++) {
+ char line[1024];
+ snprintf(line, sizeof line, "%*d. %s", numw, top + i + 1,
+ items[top + i]);
+ if (top + i == sel)
+ attron(A_REVERSE);
+ mvaddnstr(2 + i, 2, line, COLS - 4);
+ if (top + i == sel)
+ attroff(A_REVERSE);
+ }
+ if (goto_active) {
+ move(LINES - 2, 2);
+ attron(A_BOLD);
+ printw("Gå till rad: %s", gotobuf);
+ attroff(A_BOLD);
+ clrtoeol();
+ } else {
+ move(LINES - 2, 2);
+ clrtoeol();
+ }
+ {
+ char hint[256];
+ snprintf(hint, sizeof hint,
+ "upp/ned, 1-9 = hoppa, g = gå till, PgUp/PgDn, Home/End,"
+ " Enter%s%s Esc/q = tillbaka",
+ allow_refresh ? " F5 = uppdatera" : "",
+ allow_new ? " Ctrl+N = ny" : "");
+ snprintf(hint + strlen(hint), sizeof hint - strlen(hint),
+ " Ctrl+C = avsluta");
+ hints(hint);
+ }
+ refresh();
+ int ch = ui_getch();
+ if (goto_active) {
+ if (ch >= '0' && ch <= '9' && strlen(gotobuf) < 9) {
+ size_t gl = strlen(gotobuf);
+ gotobuf[gl] = (char)ch;
+ gotobuf[gl + 1] = '\0';
+ int v = atoi(gotobuf);
+ if (v >= 1 && v <= n)
+ sel = v - 1;
+ } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
+ size_t gl = strlen(gotobuf);
+ if (gl)
+ gotobuf[gl - 1] = '\0';
+ if (gotobuf[0]) {
+ int v = atoi(gotobuf);
+ if (v >= 1 && v <= n)
+ sel = v - 1;
+ }
+ } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER ||
+ ch == 27 || ch == 'g' || ch == 'G') {
+ goto_active = 0;
+ gotobuf[0] = '\0';
+ }
+ continue;
+ }
+ if (ch == KEY_UP && sel > 0)
+ sel--;
+ else if (ch == KEY_DOWN && sel < n - 1)
+ sel++;
+ else if (ch == KEY_NPAGE && sel + view < n)
+ sel += view;
+ else if (ch == KEY_PPAGE && sel - view >= 0)
+ sel -= view;
+ else if (ch == KEY_HOME)
+ sel = 0;
+ else if (ch == KEY_END)
+ sel = n - 1;
+ else if (ch >= '1' && ch <= '9') {
+ int idx = ch - '1';
+ if (idx < n)
+ sel = idx; /* goto row, Enter opens */
+ } else if (ch == 'g' || ch == 'G') {
+ goto_active = 1;
+ gotobuf[0] = '\0';
+ } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER)
+ return sel;
+ else if (allow_refresh && ch == KEY_F(5))
+ return -2;
+ else if (allow_new && ch == KEY_CTRL_N)
+ return -4;
+ else if (ch == 27 || ch == 'q')
+ return g_quit ? -5 : -1;
+ }
+}
+
+static int parse_kr(const char *s, int64_t *out)
+{
+ if (!s || !*s) {
+ *out = 0;
+ return 0;
+ }
+ int64_t whole = 0, frac = 0;
+ int fd = 0, seen_dot = 0;
+ for (const char *p = s; *p; p++) {
+ if (*p == ' ' || *p == '\t')
+ continue;
+ if (*p == '.' || *p == ',') {
+ if (seen_dot)
+ return -1;
+ seen_dot = 1;
+ continue;
+ }
+ if (*p < '0' || *p > '9')
+ return -1;
+ if (!seen_dot) {
+ whole = whole * 10 + (*p - '0');
+ } else {
+ if (fd >= 2)
+ return -1;
+ frac = frac * 10 + (*p - '0');
+ fd++;
+ }
+ }
+ if (fd == 1)
+ frac *= 10;
+ *out = whole * 100 + frac;
+ return 0;
+}
+
+static void kr_format(int64_t ore, char *buf, size_t n)
+{
+ long long v = (long long)ore;
+ if (v < 0) {
+ snprintf(buf, n, "-%lld.%02lld", -v / 100, -v % 100);
+ } else {
+ snprintf(buf, n, "%lld.%02lld", v / 100, v % 100);
+ }
+}
+
+static int parse_x_double(const char *s, double *out)
+{
+ if (!s)
+ return 0;
+ char buf[64];
+ size_t j = 0;
+ for (const char *p = s; *p && j < sizeof buf - 1; p++) {
+ if (*p == ' ' || *p == '\t')
+ continue;
+ buf[j++] = (*p == ',') ? '.' : *p;
+ }
+ buf[j] = '\0';
+ if (!j)
+ return 0;
+ char *end = NULL;
+ double v = strtod(buf, &end);
+ if (!end || *end)
+ return 0;
+ *out = v;
+ return 1;
+}
+
+static void replace_x_into(const char *src, double x, char *dst, size_t cap)
+{
+ size_t o = 0;
+ for (const char *p = src; *p && o + 1 < cap;) {
+ if (p[0] == '{' && p[1] == 'x' && p[2] == '}') {
+ char tmp[64];
+ snprintf(tmp, sizeof tmp, "%.2f", x);
+ for (size_t k = 0; tmp[k] && o + 1 < cap; k++)
+ dst[o++] = tmp[k];
+ p += 3;
+ } else {
+ dst[o++] = *p++;
+ }
+ }
+ dst[o] = '\0';
+}
+
+/* Display width in terminal columns, counting UTF-8 lead bytes. Good enough
+ for Swedish text; combining marks are ignored. */
+static int disp_width(const char *s)
+{
+ int w = 0;
+ for (const unsigned char *p = (const unsigned char *)s; *p; p++)
+ if ((*p & 0xC0) != 0x80)
+ w++;
+ return w;
+}
+
+static void pad_field(char *buf, size_t cap, int width)
+{
+ size_t slen = strlen(buf);
+ int w = 0;
+ size_t i = 0;
+ while (i < slen && buf[i]) {
+ unsigned char c = (unsigned char)buf[i];
+ size_t len = 1;
+ if ((c & 0xE0) == 0xC0)
+ len = 2;
+ else if ((c & 0xF0) == 0xE0)
+ len = 3;
+ else if ((c & 0xF8) == 0xF0)
+ len = 4;
+ if (i + len > slen || w + 1 > width)
+ break;
+ w++;
+ i += len;
+ }
+ buf[i] = '\0';
+ while (w < width && strlen(buf) + 1 < cap) {
+ strcat(buf, " ");
+ w++;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* app helpers */
+/* ------------------------------------------------------------------ */
+
+static void app_refresh_context(struct app *a)
+{
+ char args[64];
+ snprintf(args, sizeof args, "{\"org\":%lld}", (long long)a->org);
+ char *resp = client_rpc(a->fd, "session.use_org", a->session, 0, args);
+ free(resp);
+
+ resp = client_rpc(a->fd, "org.get", a->session, a->org, "{}");
+ if (resp && client_ok(resp)) {
+ char *name = jstr_dup(resp, "result.name");
+ if (name) {
+ snprintf(a->org_name, sizeof a->org_name, "%s", name);
+ free(name);
+ }
+ }
+ free(resp);
+
+ resp = client_rpc(a->fd, "org.list", a->session, 0, "{}");
+ if (resp) {
+ size_t n = jarr_size(resp, "result.items");
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ if (jint_val(resp, path, -1) == a->org) {
+ snprintf(path, sizeof path, "result.items.%zu.role", i);
+ char *role = jstr_dup(resp, path);
+ if (role) {
+ snprintf(a->role, sizeof a->role, "%s", role);
+ free(role);
+ }
+ }
+ }
+ }
+ free(resp);
+
+ snprintf(a->default_series, sizeof a->default_series, "%s", "A");
+ a->attachment_dir[0] = '\0';
+ resp = client_rpc(a->fd, "settings.get", a->session, a->org, "{}");
+ if (resp && client_ok(resp)) {
+ char *ser = jstr_dup(resp, "result.default_series");
+ if (ser && *ser)
+ snprintf(a->default_series, sizeof a->default_series, "%s", ser);
+ free(ser);
+ char *dir = jstr_dup(resp, "result.attachment_dir");
+ if (dir && *dir)
+ snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", dir);
+ free(dir);
+ }
+ free(resp);
+
+ a->max_attachment_bytes = 10 * 1024 * 1024;
+ resp = client_rpc(a->fd, "meta", NULL, 0, NULL);
+ if (resp && client_ok(resp)) {
+ int64_t v =
+ jint_val(resp, "result.limits.max_attachment_bytes", 0);
+ if (v > 0)
+ a->max_attachment_bytes = (long)v;
+ }
+ free(resp);
+
+ resp = client_rpc(a->fd, "fiscal_year.get", a->session, a->org, "{}");
+ if (resp && client_ok(resp)) {
+ a->fy = jint_val(resp, "result.id", 0);
+ char *label = jstr_dup(resp, "result.label");
+ char *start = jstr_dup(resp, "result.start_date");
+ char *end = jstr_dup(resp, "result.end_date");
+ if (label)
+ snprintf(a->fy_label, sizeof a->fy_label, "%s", label);
+ if (start)
+ snprintf(a->fy_start, sizeof a->fy_start, "%s", start);
+ if (end)
+ snprintf(a->fy_end, sizeof a->fy_end, "%s", end);
+ free(label);
+ free(start);
+ free(end);
+ }
+ free(resp);
+}
+
+static void update_status(struct app *a)
+{
+ snprintf(g_status, sizeof g_status, "%s | %s %s - %s | %s | %s",
+ a->org_name, a->fy_label, a->fy_start, a->fy_end, a->role,
+ a->username);
+}
+
+
+/* Create a new fiscal year. Returns the new id (>0) or 0. */
+static int64_t fy_new_form(struct app *a)
+{
+ char label[32] = "", start[16] = "", end[16] = "";
+ char *resp =
+ client_rpc(a->fd, "fiscal_year.list", a->session, a->org, "{}");
+ if (resp && client_ok(resp)) {
+ size_t n = jarr_size(resp, "result.items");
+ char max_end[16] = "";
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.end_date", i);
+ char *e = jstr_dup(resp, path);
+ if (e && strcmp(e, max_end) > 0)
+ snprintf(max_end, sizeof max_end, "%s", e);
+ free(e);
+ }
+ if (max_end[0])
+ util_date_add_days(max_end, 1, start, sizeof start);
+ }
+ free(resp);
+ if (!start[0]) {
+ time_t t = time(NULL);
+ struct tm tm;
+ gmtime_r(&t, &tm);
+ int y = tm.tm_year + 1900;
+ if (y < 1900 || y > 2200)
+ y = 2026;
+ snprintf(start, sizeof start, "%04d-01-01", y);
+ }
+ util_date_add_months(start, 12, end, sizeof end);
+ util_date_add_days(end, -1, end, sizeof end);
+ int sy = 0, sm = 0, sd = 0, ey = 0, em = 0, ed = 0;
+ util_date_parse(start, &sy, &sm, &sd);
+ util_date_parse(end, &ey, &em, &ed);
+ if (sm == 1 && sd == 1)
+ snprintf(label, sizeof label, "%d", ey);
+ else
+ snprintf(label, sizeof label, "%d/%d", sy, ey);
+
+ int field = 0;
+ int field_fresh = 1;
+ size_t field_pos = (size_t)-1;
+ curs_set(1);
+ for (;;) {
+ frame("Nytt räkenskapsår");
+ int caret_y = -1, caret_x = -1;
+ attron(A_BOLD);
+ mvaddstr(4, 4, "Etikett");
+ mvaddstr(6, 4, "Startdatum");
+ mvaddstr(8, 4, "Slutdatum");
+ attroff(A_BOLD);
+ {
+ const char *vals[3] = { label, start, end };
+ int ys[3] = { 4, 6, 8 };
+ for (int k = 0; k < 3; k++) {
+ char padded[64];
+ snprintf(padded, sizeof padded, "%-24s", vals[k]);
+ if (field == k) {
+ attron(A_REVERSE);
+ caret_y = ys[k];
+ size_t slen = strlen(vals[k]);
+ size_t cp = (field_fresh || field_pos == (size_t)-1 ||
+ field_pos > slen)
+ ? slen
+ : field_pos;
+ caret_x = 16 + (int)disp_width_n(vals[k], cp);
+ }
+ mvaddstr(ys[k], 16, padded);
+ if (field == k)
+ attroff(A_REVERSE);
+ }
+ }
+ hints("Tab = byta fält F5 = validera F9 = skapa Esc = avbryt");
+ if (caret_y >= 0)
+ move(caret_y, caret_x);
+ refresh();
+
+ int ch = ui_getch();
+ if (ch == 27) {
+ curs_set(0);
+ return 0;
+ }
+ if (ch == '\t') {
+ field = (field + 1) % 3;
+ field_fresh = 1;
+ continue;
+ }
+ if (ch == KEY_BTAB) {
+ field = (field + 2) % 3;
+ field_fresh = 1;
+ continue;
+ }
+ if (ch == KEY_F(5) || ch == KEY_F(9)) {
+ if (!label[0]) {
+ message("Räkenskapsår", "Etiketten får inte vara tom.");
+ continue;
+ }
+ if (!util_parse_iso_date(start) || !util_parse_iso_date(end)) {
+ message("Räkenskapsår",
+ "Start- och slutdatum måste vara ÅÅÅÅ-MM-DD.");
+ continue;
+ }
+ if (strcmp(start, end) >= 0) {
+ message("Räkenskapsår",
+ "Startdatum måste ligga före slutdatum.");
+ continue;
+ }
+ if (ch == KEY_F(5)) {
+ message("Validering OK", "%s %s - %s", label, start, end);
+ continue;
+ }
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "label", label);
+ yyjson_mut_obj_add_strcpy(d, o, "start_date", start);
+ yyjson_mut_obj_add_strcpy(d, o, "end_date", end);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ resp = client_rpc(a->fd, "fiscal_year.open", a->session, a->org,
+ args);
+ free(args);
+ if (resp && client_ok(resp)) {
+ int64_t id = jint_val(resp, "result.id", 0);
+ message("Räkenskapsår", "%s skapat.", label);
+ free(resp);
+ curs_set(0);
+ return id;
+ }
+ show_error("Kunde inte skapa räkenskapsåret", resp);
+ free(resp);
+ continue;
+ }
+ char *buf = field == 0 ? label : (field == 1 ? start : end);
+ size_t cap = field == 0 ? sizeof label
+ : (field == 1 ? sizeof start : sizeof end);
+ if (field == 0)
+ field_edit(buf, cap, &field_pos, ch, &field_fresh);
+ else
+ date_field_edit(buf, cap, &field_pos, ch, &field_fresh);
+ }
+}
+
+/* Pick which fiscal year the screens work in. Shown as its real range so
+ broken fiscal years (not starting in January) are obvious. */
+static void select_fiscal_year(struct app *a)
+{
+ int64_t prefer = a->fy;
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp =
+ client_rpc(a->fd, "fiscal_year.list", a->session, a->org, "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Räkenskapsår", resp);
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ if (n == 0) {
+ free(resp);
+ int64_t id = fy_new_form(a);
+ if (id > 0)
+ prefer = id;
+ continue;
+ }
+ char **lines = xcalloc(n, sizeof(char *));
+ int64_t *ids = xcalloc(n, sizeof(int64_t));
+ char (*ranges)[40] = xcalloc(n, sizeof *ranges);
+ char (*labels)[64] = xcalloc(n, sizeof *labels);
+ int sel_default = 0;
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ ids[i] = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.label", i);
+ char *label = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.start_date", i);
+ char *sdate = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.end_date", i);
+ char *edate = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.status", i);
+ char *status = jstr_dup(resp, path);
+ snprintf(ranges[i], sizeof ranges[i], "%s - %s",
+ sdate ? sdate : "?", edate ? edate : "?");
+ snprintf(labels[i], sizeof labels[i], "%s", label ? label : "");
+ char line[256];
+ snprintf(line, sizeof line, "%-25s %-5s %s", ranges[i],
+ strcmp(status ? status : "", "closed") == 0 ? "stängd"
+ : "öppen",
+ labels[i]);
+ lines[i] = xstrdup(line);
+ if (ids[i] == prefer)
+ sel_default = (int)i;
+ free(label);
+ free(sdate);
+ free(edate);
+ free(status);
+ }
+ int sel = select_list("Välj räkenskapsår", lines, (int)n, sel_default,
+ 0, NULL, 1);
+ for (size_t i = 0; i < n; i++)
+ free(lines[i]);
+ free(lines);
+ free(resp);
+ if (sel == -4) {
+ free(ids);
+ free(ranges);
+ free(labels);
+ int64_t id = fy_new_form(a);
+ if (id > 0)
+ prefer = id;
+ continue;
+ }
+ if (sel < 0) {
+ free(ids);
+ free(ranges);
+ free(labels);
+ return;
+ }
+ a->fy = ids[sel];
+ snprintf(a->fy_label, sizeof a->fy_label, "%s", labels[sel]);
+ /* range is "start - end" */
+ snprintf(a->fy_start, sizeof a->fy_start, "%.10s", ranges[sel]);
+ snprintf(a->fy_end, sizeof a->fy_end, "%s", ranges[sel] + 13);
+ free(ids);
+ free(ranges);
+ free(labels);
+ a->voucher_sel = 0;
+ update_status(a);
+ return;
+ }
+}
+
+static void show_error(const char *title, const char *resp)
+{
+ char *code = jstr_dup(resp, "error.code");
+ char *msg = jstr_dup(resp, "error.message");
+ if ((!code || !*code) && (!msg || !*msg)) {
+ message(title, "%s", resp && *resp
+ ? resp
+ : "Inget svar från servern (kör daemonen?)");
+ } else {
+ message(title, "%s: %s", code && *code ? code : "fel",
+ msg && *msg ? msg : "okänt fel");
+ }
+ free(code);
+ free(msg);
+}
+
+static char *read_file_b64(const char *path)
+{
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ return NULL;
+ struct buf b;
+ buf_init(&b);
+ unsigned char chunk[65536];
+ size_t rn;
+ while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0)
+ buf_append(&b, chunk, rn);
+ int bad = ferror(f);
+ fclose(f);
+ if (bad) {
+ buf_free(&b);
+ return NULL;
+ }
+ char *b64 = util_b64(b.p ? b.p : (const unsigned char *)"", b.len);
+ buf_free(&b);
+ return b64;
+}
+
+/* ------------------------------------------------------------------ */
+/* screens */
+/* ------------------------------------------------------------------ */
+
+struct fentry {
+ char name[300];
+ int isdir;
+};
+
+static int fentry_cmp(const void *a, const void *b)
+{
+ const struct fentry *x = a;
+ const struct fentry *y = b;
+ if (x->isdir != y->isdir)
+ return y->isdir - x->isdir;
+ return strcasecmp(x->name, y->name);
+}
+
+/* Expands a leading ~ to $HOME (no ~user support). */
+static void expand_path(const char *in, char *out, size_t n)
+{
+ if (in && in[0] == '~' && (in[1] == '\0' || in[1] == '/')) {
+ const char *home = getenv("HOME");
+ if (home)
+ snprintf(out, n, "%s%s", home, in + 1);
+ else
+ snprintf(out, n, "%s", in[1] ? in + 2 : ".");
+ } else {
+ snprintf(out, n, "%s", in ? in : ".");
+ }
+}
+
+/* File browser for choosing underlag. Starts in start_dir or $HOME.
+ Returns a malloc'd path, or NULL on cancel. */
+static char *file_browser(struct app *a, const char *start_dir)
+{
+ (void)a;
+ if (g_quit)
+ return NULL;
+ char dir[1024];
+ const char *start = (start_dir && *start_dir) ? start_dir : getenv("HOME");
+ expand_path(start, dir, sizeof dir);
+ if (!dir[0])
+ snprintf(dir, sizeof dir, ".");
+ for (;;) {
+ DIR *d = opendir(dir);
+ if (!d) {
+ message("Filväljare", "Kan inte öppna %s", dir);
+ return NULL;
+ }
+ struct fentry *ents = NULL;
+ size_t n = 0, cap = 0;
+ struct dirent *de;
+ while ((de = readdir(d)) != NULL) {
+ if (strcmp(de->d_name, ".") == 0 ||
+ strcmp(de->d_name, "..") == 0)
+ continue; /* ".." is the first item in the list */
+ if (de->d_name[0] == '.')
+ continue; /* hidden files */
+ char full[1600];
+ snprintf(full, sizeof full, "%.1200s/%.299s", dir, de->d_name);
+ struct stat st;
+ if (stat(full, &st) != 0)
+ continue;
+ if (n == cap) {
+ cap = cap ? cap * 2 : 64;
+ ents = xrealloc(ents, cap * sizeof *ents);
+ }
+ snprintf(ents[n].name, sizeof ents[n].name, "%s", de->d_name);
+ ents[n].isdir = S_ISDIR(st.st_mode);
+ n++;
+ if (n >= 2000)
+ break;
+ }
+ closedir(d);
+ qsort(ents, n, sizeof *ents, fentry_cmp);
+ char **items = xcalloc(n + 1, sizeof(char *));
+ items[0] = xstrdup(".. (uppåt)");
+ for (size_t i = 0; i < n; i++) {
+ char line[340];
+ snprintf(line, sizeof line, "%s%s", ents[i].name,
+ ents[i].isdir ? "/" : "");
+ items[i + 1] = xstrdup(line);
+ }
+ char title[1200];
+ snprintf(title, sizeof title, "Välj fil — %.200s", dir);
+ int sel = select_list(title, items, (int)n + 1, 0, 1, NULL, 0);
+ for (size_t i = 0; i <= n; i++)
+ free(items[i]);
+ free(items);
+ if (sel < 0) {
+ free(ents);
+ return NULL;
+ }
+ if (sel == 0) {
+ char *slash = strrchr(dir, '/');
+ if (slash && slash != dir)
+ *slash = '\0';
+ else if (slash == dir)
+ dir[1] = '\0';
+ free(ents);
+ continue;
+ }
+ size_t idx = (size_t)sel - 1;
+ char full[1600];
+ snprintf(full, sizeof full, "%.1200s/%.299s", dir, ents[idx].name);
+ if (ents[idx].isdir) {
+ snprintf(dir, sizeof dir, "%.1023s", full);
+ free(ents);
+ continue;
+ }
+ free(ents);
+ return xstrdup(full);
+ }
+}
+
+static int voucher_detail(struct app *a, int64_t id, int64_t *out_new)
+{
+ if (out_new)
+ *out_new = 0;
+ char args[64];
+ snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id);
+ for (;;) {
+ char *resp =
+ client_rpc(a->fd, "voucher.get", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Verifikat", resp);
+ free(resp);
+ return 0;
+ }
+ struct buf text;
+ buf_init(&text);
+ char *series = jstr_dup(resp, "result.series");
+ char *date = jstr_dup(resp, "result.date");
+ char *desc = jstr_dup(resp, "result.description");
+ char *hash = jstr_dup(resp, "result.hash");
+ int64_t number = jint_val(resp, "result.number", 0);
+ char line[1024];
+ snprintf(line, sizeof line, "%s%lld %s %s\n\n",
+ series ? series : "", (long long)number, date ? date : "",
+ desc ? desc : "");
+ buf_append(&text, line, strlen(line));
+ size_t nrows = jarr_size(resp, "result.rows");
+ for (size_t i = 0; i < nrows; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.rows.%zu.account", i);
+ char *acc = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.name", i);
+ char *name = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.debit_ore", i);
+ int64_t debit = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.rows.%zu.credit_ore", i);
+ int64_t credit = jint_val(resp, path, 0);
+ char d[32], c[32];
+ kr_format(debit, d, sizeof d);
+ kr_format(credit, c, sizeof c);
+ char acol[32], ncol[256];
+ snprintf(acol, sizeof acol, "%s", acc ? acc : "");
+ pad_field(acol, sizeof acol, 6);
+ snprintf(ncol, sizeof ncol, "%s", name ? name : "");
+ pad_field(ncol, sizeof ncol, 34);
+ snprintf(line, sizeof line, " %s %s D %12s K %12s\n", acol,
+ ncol, d, c);
+ buf_append(&text, line, strlen(line));
+ free(acc);
+ free(name);
+ }
+ size_t natts = jarr_size(resp, "result.attachments");
+ if (natts) {
+ buf_append(&text, "\nUnderlag:\n", 11);
+ for (size_t i = 0; i < natts; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.attachments.%zu.filename",
+ i);
+ char *fn = jstr_dup(resp, path);
+ snprintf(line, sizeof line, " %s\n", fn ? fn : "");
+ buf_append(&text, line, strlen(line));
+ free(fn);
+ }
+ }
+ if (hash) {
+ snprintf(line, sizeof line, "\nHash: %s\n", hash);
+ buf_append(&text, line, strlen(line));
+ }
+ int64_t corrects = jint_val(resp, "result.corrects_voucher_id", 0);
+ if (corrects) {
+ snprintf(line, sizeof line, "Rättar verifikat: %lld\n",
+ (long long)corrects);
+ buf_append(&text, line, strlen(line));
+ }
+ buf_append(&text, "\0", 1);
+
+ int nlines = 0;
+ for (size_t i = 0; i < text.len && text.p[i]; i++)
+ if (text.p[i] == '\n')
+ nlines++;
+ char **lines = xcalloc(nlines + 2, sizeof(char *));
+ int n = 0;
+ char *copy = xstrdup((char *)text.p);
+ for (char *p = copy;;) {
+ char *nl = strchr(p, '\n');
+ if (nl)
+ *nl = '\0';
+ lines[n++] = p;
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ int top = 0, view = LINES - 4;
+ int want_refresh = 0, corrected = 0, stop = 0, posted_new = 0;
+ int64_t posted_id = 0;
+ while (!stop) {
+ frame("Verifikat");
+ for (int i = 0; i < view && top + i < n; i++)
+ mvaddnstr(2 + i, 2, lines[top + i], COLS - 4);
+ hints("upp/ned F5 = uppdatera Ctrl+N = nytt verifikat c ="
+ " rätta Esc/q = tillbaka");
+ refresh();
+ int ch = ui_getch();
+ if (ch == 'q' || ch == 27) {
+ stop = 1;
+ } else if (ch == KEY_F(5)) {
+ want_refresh = 1;
+ stop = 1;
+ } else if (ch == KEY_CTRL_N) {
+ int64_t nid = vouchers_new(a);
+ if (nid > 0) {
+ posted_id = nid;
+ posted_new = 1;
+ stop = 1;
+ }
+ } else if (ch == 'c') {
+ char *reason = prompt("Beskriv rättelsen: ", "", 0);
+ if (reason && *reason) {
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_int(d, o, "voucher", id);
+ yyjson_mut_obj_add_strcpy(d, o, "description", reason);
+ char *ref = util_random_id("tui-", 8);
+ yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref);
+ char *cargs = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *r = client_rpc(a->fd, "voucher.correct", a->session,
+ a->org, cargs);
+ if (r && client_ok(r)) {
+ int64_t new_id = jint_val(r, "result.id", 0);
+ message("Rättat", "Ändringsverifikat %lld skapat",
+ (long long)new_id);
+ corrected = 1;
+ } else {
+ show_error("Kunde inte rätta", r);
+ }
+ free(r);
+ free(cargs);
+ free(ref);
+ }
+ stop = 1;
+ } else if (ch == KEY_DOWN && top + view < n) {
+ top++;
+ } else if (ch == KEY_UP && top > 0) {
+ top--;
+ } else if (ch == KEY_NPAGE) {
+ top += view;
+ } else if (ch == KEY_PPAGE) {
+ top -= view;
+ }
+ if (top + view > n)
+ top = n > view ? n - view : 0;
+ if (top < 0)
+ top = 0;
+ }
+ free(copy);
+ free(lines);
+ free(series);
+ free(date);
+ free(desc);
+ free(hash);
+ buf_free(&text);
+ free(resp);
+ if (posted_new) {
+ if (out_new)
+ *out_new = posted_id;
+ return 2;
+ }
+ if (want_refresh)
+ continue;
+ return corrected;
+ }
+}
+
+struct tui_vrow {
+ char account[16];
+ char debit[32];
+ char credit[32];
+ char text[64];
+};
+
+static int row_has_content(const struct tui_vrow *r)
+{
+ return r->account[0] || r->debit[0] || r->credit[0] || r->text[0];
+}
+
+/* Keeps exactly one empty trailing row: appends one when the last row has
+ content, and collapses an empty row that is followed by another empty
+ row (the row the user just cleared). Adjusts *field so the selection
+ stays on the same logical cell. */
+static void normalize_rows(struct tui_vrow *rows, int *nrows, int *field)
+{
+ while (*nrows < 64 && row_has_content(&rows[*nrows - 1])) {
+ memset(&rows[*nrows], 0, sizeof rows[0]);
+ (*nrows)++;
+ }
+ int i = 0;
+ while (i < *nrows - 1) {
+ if (!row_has_content(&rows[i]) && !row_has_content(&rows[i + 1])) {
+ int fr = *field >= 3 ? (*field - 3) / 4 : -1;
+ if (fr > i)
+ *field -= 4;
+ memmove(&rows[i], &rows[i + 1],
+ (size_t)(*nrows - i - 1) * sizeof rows[0]);
+ memset(&rows[*nrows - 1], 0, sizeof rows[0]);
+ (*nrows)--;
+ continue;
+ }
+ i++;
+ }
+ if (*nrows < 1) {
+ *nrows = 1;
+ memset(&rows[0], 0, sizeof rows[0]);
+ if (*field >= 3)
+ *field = 3;
+ }
+}
+
+struct acct_cache {
+ int loaded;
+ int64_t org;
+ char **numbers;
+ char **names;
+ size_t n;
+};
+
+static struct acct_cache g_accts;
+
+static void acct_cache_free(void)
+{
+ for (size_t i = 0; i < g_accts.n; i++) {
+ free(g_accts.numbers[i]);
+ free(g_accts.names[i]);
+ }
+ free(g_accts.numbers);
+ free(g_accts.names);
+ memset(&g_accts, 0, sizeof g_accts);
+}
+
+static void acct_cache_load(struct app *a)
+{
+ if (g_accts.loaded && g_accts.org == a->org)
+ return;
+ acct_cache_free();
+ char *resp = client_rpc(a->fd, "account.list", a->session, a->org,
+ "{\"active_only\":true}");
+ if (!resp || !client_ok(resp)) {
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ g_accts.numbers = xcalloc(n ? n : 1, sizeof(char *));
+ g_accts.names = xcalloc(n ? n : 1, sizeof(char *));
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.number", i);
+ char *number = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *name = jstr_dup(resp, path);
+ g_accts.numbers[i] = number ? number : xstrdup("");
+ g_accts.names[i] = name ? name : xstrdup("");
+ }
+ g_accts.n = n;
+ g_accts.org = a->org;
+ g_accts.loaded = 1;
+ free(resp);
+}
+
+static const char *acct_name(struct app *a, const char *number)
+{
+ if (!number || !*number)
+ return NULL;
+ acct_cache_load(a);
+ for (size_t i = 0; i < g_accts.n; i++)
+ if (strcmp(g_accts.numbers[i], number) == 0)
+ return g_accts.names[i];
+ return NULL;
+}
+
+static int acct_exists(struct app *a, const char *number)
+{
+ if (!number || !*number)
+ return 0;
+ acct_cache_load(a);
+ for (size_t i = 0; i < g_accts.n; i++)
+ if (strcmp(g_accts.numbers[i], number) == 0)
+ return 1;
+ return 0;
+}
+
+static int64_t vouchers_new(struct app *a)
+{
+ static struct tui_vrow rows[64];
+ char date[16], desc[256], series[16], client_ref[64];
+ int64_t att_ids[32];
+ char att_names[32][80];
+ int natt = 0;
+ curs_set(1);
+ memset(rows, 0, sizeof rows);
+ snprintf(date, sizeof date, "%s",
+ a->fy_start[0] ? a->fy_start : "2026-01-01");
+ desc[0] = '\0';
+ snprintf(series, sizeof series, "%s", a->default_series);
+ snprintf(client_ref, sizeof client_ref, "%s", util_random_id("tui-", 8));
+ int nrows = 1;
+ int field = 0;
+ int field_fresh = 1;
+ size_t field_pos = (size_t)-1;
+ int nfields = 3 + 4 * nrows;
+ const int x_account = 2;
+ const int name_w = 24;
+ const int x_name = 9;
+ const int x_debit = 34;
+ const int x_credit = 46;
+ const int x_text = 58;
+ int text_w = COLS - x_text - 2;
+ if (text_w < 8)
+ text_w = 8;
+
+ for (;;) {
+ frame("Nytt verifikat");
+ int caret_y = -1, caret_x = -1;
+ attron(A_BOLD);
+ mvaddstr(2, 2, "Datum");
+ mvaddstr(2, 22, "Serie");
+ mvaddstr(4, 2, "Text");
+ attroff(A_BOLD);
+ {
+ /* field order is 0 = date, 1 = series, 2 = text */
+ const char *hvals[3] = { date, series, desc };
+ int hx[3] = { 9, 29, 9 };
+ int hy[3] = { 2, 2, 4 };
+ int hw[3] = { 10, 8, COLS - 12 };
+ for (int k = 0; k < 3; k++) {
+ int w = hw[k] > 1 ? hw[k] : 1;
+ char padded[512];
+ snprintf(padded, sizeof padded, "%s", hvals[k]);
+ pad_field(padded, sizeof padded, w);
+ if (field == k) {
+ attron(A_REVERSE);
+ caret_y = hy[k];
+ int cw = disp_width(hvals[k]);
+ caret_x = hx[k] + (cw > w ? w : cw);
+ }
+ mvaddstr(hy[k], hx[k], padded);
+ if (field == k)
+ attroff(A_REVERSE);
+ }
+ }
+ attron(A_BOLD);
+ mvaddstr(6, x_account, "Konto");
+ mvaddstr(6, x_name, "Namn");
+ mvaddstr(6, x_debit, "Debet");
+ mvaddstr(6, x_credit, "Kredit");
+ mvaddstr(6, x_text, "Text");
+ attroff(A_BOLD);
+
+ int64_t sum_d = 0, sum_c = 0;
+ for (int i = 0; i < nrows; i++) {
+ int y = 7 + i;
+ int64_t v;
+ if (parse_kr(rows[i].debit, &v) == 0)
+ sum_d += v;
+ if (parse_kr(rows[i].credit, &v) == 0)
+ sum_c += v;
+ int sel_ci = -1;
+ if (field >= 3) {
+ int ri = (field - 3) / 4, ci = (field - 3) % 4;
+ if (ri == i)
+ sel_ci = ci;
+ }
+ const char *vals[4] = { rows[i].account, rows[i].debit,
+ rows[i].credit, rows[i].text };
+ int xs[4] = { x_account, x_debit, x_credit, x_text };
+ int ws[4] = { 6, 11, 11, text_w };
+ for (int k = 0; k < 4; k++) {
+ int w = ws[k] > 1 ? ws[k] : 1;
+ char padded[512];
+ snprintf(padded, sizeof padded, "%s", vals[k]);
+ pad_field(padded, sizeof padded, w);
+ if (k == sel_ci) {
+ attron(A_REVERSE);
+ caret_y = y;
+ size_t slen = strlen(vals[k]);
+ size_t cp = (field_fresh || field_pos == (size_t)-1 ||
+ field_pos > slen)
+ ? slen
+ : field_pos;
+ int cw = (int)disp_width_n(vals[k], cp);
+ caret_x = xs[k] + (cw > w ? w : cw);
+ }
+ mvaddstr(y, xs[k], padded);
+ if (k == sel_ci)
+ attroff(A_REVERSE);
+ }
+ {
+ char nbuf[512];
+ const char *nm = acct_name(a, rows[i].account);
+ snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : "");
+ pad_field(nbuf, sizeof nbuf, name_w);
+ attron(A_DIM);
+ mvaddstr(y, x_name, nbuf);
+ attroff(A_DIM);
+ }
+ }
+ char d1[32], c1[32], diff[32];
+ kr_format(sum_d, d1, sizeof d1);
+ kr_format(sum_c, c1, sizeof c1);
+ kr_format(sum_d - sum_c, diff, sizeof diff);
+ int balanced = sum_d == sum_c;
+ move(7 + nrows, 2);
+ clrtoeol();
+ if (natt > 0) {
+ char aline[4096] = "Bilagor: ";
+ for (int i = 0; i < natt; i++) {
+ size_t used = strlen(aline);
+ if (used + 1 >= sizeof aline)
+ break;
+ snprintf(aline + used, sizeof aline - used, "%.79s%s",
+ att_names[i], i + 1 < natt ? ", " : "");
+ }
+ attron(A_DIM);
+ mvaddnstr(7 + nrows, 2, aline, COLS - 4);
+ attroff(A_DIM);
+ }
+ attron(A_BOLD);
+ mvprintw(7 + nrows + 1, 2, "Summa debet %12s kredit %12s ", d1, c1);
+ mvprintw(7 + nrows + 1, 58, "%s", balanced ? "I BALANS" : "DIFF");
+ attroff(A_BOLD);
+ if (!balanced)
+ mvprintw(7 + nrows + 2, 2, "Differens: %s", diff);
+ hints("F4 = mall Ctrl+F = bifoga fil Tab = byta fält F5 = validera"
+ " F7 = rensa rad F9 = bokför Esc = avbryt");
+ if (caret_y >= 0)
+ move(caret_y, caret_x);
+ refresh();
+
+ int ch = ui_getch();
+ if (ch == 27) {
+ curs_set(0);
+ return 0;
+ }
+ if (ch == '\t') {
+ field = (field + 1) % nfields;
+ field_fresh = 1;
+ continue;
+ }
+ if (ch == KEY_BTAB) {
+ field = (field + nfields - 1) % nfields;
+ field_fresh = 1;
+ continue;
+ }
+ if (ch == KEY_UP) {
+ if (field >= 3) {
+ int ci = (field - 3) % 4;
+ int ri = (field - 3) / 4;
+ if (ri > 0) {
+ field = 3 + (ri - 1) * 4 + ci;
+ field_fresh = 1;
+ }
+ }
+ continue;
+ }
+ if (ch == KEY_DOWN) {
+ if (field >= 3) {
+ int ci = (field - 3) % 4;
+ int ri = (field - 3) / 4;
+ if (ri + 1 < nrows) {
+ field = 3 + (ri + 1) * 4 + ci;
+ field_fresh = 1;
+ }
+ }
+ continue;
+ }
+ if (ch == KEY_F(4)) {
+ char tname[128] = "";
+ char *lresp = client_rpc(a->fd, "template.list", a->session,
+ a->org, "{\"active_only\":true}");
+ if (!lresp || !client_ok(lresp)) {
+ show_error("Mallar", lresp);
+ free(lresp);
+ continue;
+ }
+ size_t ln = jarr_size(lresp, "result.items");
+ if (ln == 0) {
+ message("Mallar",
+ "Inga mallar ännu. Skapa en under Mallar först.");
+ free(lresp);
+ continue;
+ }
+ char **names = xcalloc(ln, sizeof(char *));
+ char **lines = xcalloc(ln, sizeof(char *));
+ for (size_t i = 0; i < ln; i++) {
+ char path[64], line[512];
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *nm = jstr_dup(lresp, path);
+ snprintf(path, sizeof path, "result.items.%zu.series", i);
+ char *ser = jstr_dup(lresp, path);
+ snprintf(path, sizeof path, "result.items.%zu.row_count", i);
+ int64_t rc = jint_val(lresp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.description",
+ i);
+ char *ds = jstr_dup(lresp, path);
+ snprintf(line, sizeof line, "%-24s %-3s %2lld rader %s",
+ nm ? nm : "", ser ? ser : "", (long long)rc,
+ ds ? ds : "");
+ names[i] = xstrdup(nm ? nm : "");
+ lines[i] = xstrdup(line);
+ free(nm);
+ free(ser);
+ free(ds);
+ }
+ int tsel = select_list("Välj mall", lines, (int)ln, 0, 1, NULL, 0);
+ if (tsel >= 0)
+ snprintf(tname, sizeof tname, "%s", names[tsel]);
+ for (size_t i = 0; i < ln; i++) {
+ free(names[i]);
+ free(lines[i]);
+ }
+ free(names);
+ free(lines);
+ free(lresp);
+ if (tsel < 0)
+ continue;
+ {
+ char tbuf[128];
+ snprintf(tbuf, sizeof tbuf, "%s", tname);
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *to = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, to);
+ yyjson_mut_obj_add_strcpy(d, to, "name", tbuf);
+ char *targs = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *resp = client_rpc(a->fd, "template.get", a->session,
+ a->org, targs);
+ free(targs);
+ if (!resp || !client_ok(resp)) {
+ show_error("Mall", resp);
+ free(resp);
+ } else {
+ char *xs = prompt("Belopp (x, kr): ", "", 0);
+ double xv = 0;
+ if (xs && *xs && !parse_x_double(xs, &xv)) {
+ message("Fel", "Ogiltigt belopp.");
+ free(resp);
+ continue;
+ }
+ size_t n = jarr_size(resp, "result.rows");
+ char **accts = xcalloc(n ? n : 1, sizeof(char *));
+ char **forms = xcalloc(n ? n : 1, sizeof(char *));
+ char **descs = xcalloc(n ? n : 1, sizeof(char *));
+ struct template_row *in = xcalloc(n ? n : 1, sizeof *in);
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.rows.%zu.account",
+ i);
+ accts[i] = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.formula",
+ i);
+ forms[i] = jstr_dup(resp, path);
+ snprintf(path, sizeof path,
+ "result.rows.%zu.description", i);
+ descs[i] = jstr_dup(resp, path);
+ in[i].account = accts[i] ? accts[i] : "";
+ in[i].formula = forms[i] ? forms[i] : "";
+ in[i].description =
+ descs[i] && *descs[i] ? descs[i] : NULL;
+ }
+ struct resolved_row *out =
+ xcalloc(n ? n : 1, sizeof *out);
+ size_t on = 0;
+ char ferr[256] = "";
+ if (formula_resolve_rows(in, n, xv, out, &on, ferr,
+ sizeof ferr) != 0) {
+ message("Mall", "%s",
+ ferr[0] ? ferr : "kunde inte lösa mallen");
+ } else {
+ memset(rows, 0, sizeof rows);
+ int use = (int)(on > 64 ? 64 : on);
+ for (int i = 0; i < use; i++) {
+ snprintf(rows[i].account, sizeof rows[i].account,
+ "%s", out[i].account);
+ char tmp[32];
+ if (out[i].debit_ore) {
+ kr_format(out[i].debit_ore, tmp, sizeof tmp);
+ snprintf(rows[i].debit, sizeof rows[i].debit,
+ "%s", tmp);
+ } else {
+ kr_format(out[i].credit_ore, tmp,
+ sizeof tmp);
+ snprintf(rows[i].credit,
+ sizeof rows[i].credit, "%s", tmp);
+ }
+ if (out[i].description[0])
+ snprintf(rows[i].text, sizeof rows[i].text,
+ "%s", out[i].description);
+ }
+ nrows = use > 0 ? use : 1;
+ char *tds = jstr_dup(resp, "result.description");
+ char *tser = jstr_dup(resp, "result.series");
+ if (!desc[0] && tds && *tds)
+ replace_x_into(tds, xv, desc, sizeof desc);
+ if (tser && *tser)
+ snprintf(series, sizeof series, "%s", tser);
+ free(tds);
+ free(tser);
+ field = 3;
+ field_fresh = 1;
+ normalize_rows(rows, &nrows, &field);
+ nfields = 3 + 4 * nrows;
+ }
+ free(out);
+ for (size_t i = 0; i < n; i++) {
+ free(accts[i]);
+ free(forms[i]);
+ free(descs[i]);
+ }
+ free(accts);
+ free(forms);
+ free(descs);
+ free(in);
+ free(resp);
+ }
+ }
+ continue;
+ }
+ if (ch == 6) { /* Ctrl+F: attach a file */
+ char *path = file_browser(a, a->attachment_dir);
+ if (path) {
+ struct stat sb;
+ if (stat(path, &sb) == 0 &&
+ (long)sb.st_size > a->max_attachment_bytes) {
+ message("Bilaga", "Filen är %.1f MB, max %ld MB.",
+ (double)sb.st_size / (1024 * 1024),
+ a->max_attachment_bytes / (1024 * 1024));
+ free(path);
+ continue;
+ }
+ char *b64 = read_file_b64(path);
+ if (!b64) {
+ message("Bilaga", "Kunde inte läsa %s", path);
+ } else if (natt >= 32) {
+ message("Bilaga", "Max 32 bilagor per verifikat.");
+ } else {
+ const char *base = strrchr(path, '/');
+ base = base ? base + 1 : path;
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "filename", base);
+ yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ size_t need = strlen(args) + 256;
+ char *raw = xmalloc(need);
+ snprintf(raw, need,
+ "{\"v\":1,\"id\":\"tui\",\"cmd\":"
+ "\"attachment.put\",\"session\":\"%s\","
+ "\"org\":%lld,\"args\":%s}",
+ a->session, (long long)a->org, args);
+ free(args);
+ char *r = NULL;
+ if (client_send_line(a->fd, raw) == 0)
+ r = client_read_line(a->fd);
+ free(raw);
+ if (r && client_ok(r)) {
+ att_ids[natt] = jint_val(r, "result.id", 0);
+ snprintf(att_names[natt], sizeof att_names[natt], "%s",
+ base);
+ natt++;
+ } else {
+ show_error("Kunde inte bifoga filen", r);
+ }
+ free(r);
+ }
+ free(b64);
+ free(path);
+ }
+ continue;
+ }
+ if (ch == KEY_F(7)) {
+ if (field >= 3) {
+ int ri = (field - 3) / 4;
+ if (ri < nrows) {
+ memset(&rows[ri], 0, sizeof rows[ri]);
+ normalize_rows(rows, &nrows, &field);
+ nfields = 3 + 4 * nrows;
+ field_fresh = 1;
+ }
+ }
+ continue;
+ }
+
+ /* build args for validate/post */
+ int do_post = (ch == KEY_F(9));
+ int do_check = (ch == KEY_F(5));
+ if (do_post || do_check) {
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "date", date);
+ yyjson_mut_obj_add_strcpy(d, o, "description", desc);
+ yyjson_mut_obj_add_strcpy(d, o, "series", series);
+ yyjson_mut_obj_add_strcpy(d, o, "client_ref", client_ref);
+ yyjson_mut_val *arr = yyjson_mut_arr(d);
+ for (int i = 0; i < nrows; i++) {
+ if (!rows[i].account[0])
+ continue;
+ int64_t dv = 0, cv = 0;
+ if (parse_kr(rows[i].debit, &dv) != 0 ||
+ parse_kr(rows[i].credit, &cv) != 0) {
+ message("Fel", "Ogiltigt belopp på rad %d", i + 1);
+ yyjson_mut_doc_free(d);
+ goto next_key;
+ }
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
+ yyjson_mut_obj_add_strcpy(d, ro, "account", rows[i].account);
+ yyjson_mut_obj_add_int(d, ro, "debit_ore", dv);
+ yyjson_mut_obj_add_int(d, ro, "credit_ore", cv);
+ if (rows[i].text[0])
+ yyjson_mut_obj_add_strcpy(d, ro, "description",
+ rows[i].text);
+ }
+ yyjson_mut_obj_add_val(d, o, "rows", arr);
+ if (natt > 0) {
+ yyjson_mut_val *aa = yyjson_mut_arr(d);
+ for (int i = 0; i < natt; i++)
+ yyjson_mut_arr_add_int(d, aa, att_ids[i]);
+ yyjson_mut_obj_add_val(d, o, "attachment_ids", aa);
+ }
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ if (do_check) {
+ char *raw = xmalloc(strlen(args) + 256);
+ sprintf(raw,
+ "{\"v\":1,\"id\":\"tui\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%lld,\"dry_run\":true,"
+ "\"args\":%s}",
+ a->session, (long long)a->org, args);
+ if (client_send_line(a->fd, raw) == 0) {
+ char *r = client_read_line(a->fd);
+ if (r && client_ok(r)) {
+ int64_t num = jint_val(r, "result.number", 0);
+ message("Validering OK",
+ "Nummer %lld skulle tilldelas.", (long long)num);
+ } else {
+ show_error("Validering misslyckades", r);
+ }
+ free(r);
+ } else {
+ message("Fel", "Kunde inte nå servern");
+ }
+ free(raw);
+ } else {
+ char *r = client_rpc(a->fd, "voucher.post", a->session, a->org,
+ args);
+ if (r && client_ok(r)) {
+ int64_t id = jint_val(r, "result.id", 0);
+ int64_t num = jint_val(r, "result.number", 0);
+ char *ser = jstr_dup(r, "result.series");
+ int replayed = jbool_val(r, "result.replayed", 0);
+ message("Bokfört", "%s%lld (id %lld)%s", ser ? ser : "",
+ (long long)num, (long long)id,
+ replayed ? " — redan bokfört (idempotent)" : "");
+ free(ser);
+ free(r);
+ free(args);
+ curs_set(0);
+ return id;
+ }
+ show_error("Kunde inte bokföra", r);
+ free(r);
+ }
+ free(args);
+ next_key:
+ continue;
+ }
+
+ /* text input into current field */
+ char *buf = NULL;
+ size_t cap = 0;
+ if (field == 0) {
+ buf = date;
+ cap = sizeof date;
+ } else if (field == 1) {
+ buf = series;
+ cap = sizeof series;
+ } else if (field == 2) {
+ buf = desc;
+ cap = sizeof desc;
+ } else {
+ int ri = (field - 3) / 4, ci = (field - 3) % 4;
+ if (ri < nrows) {
+ switch (ci) {
+ case 0:
+ buf = rows[ri].account;
+ cap = sizeof rows[ri].account;
+ break;
+ case 1:
+ buf = rows[ri].debit;
+ cap = sizeof rows[ri].debit;
+ break;
+ case 2:
+ buf = rows[ri].credit;
+ cap = sizeof rows[ri].credit;
+ break;
+ default:
+ buf = rows[ri].text;
+ cap = sizeof rows[ri].text;
+ break;
+ }
+ }
+ }
+ if (!buf)
+ continue;
+ /* first keystroke in a freshly focused field replaces its content
+ (so a prefilled date can be typed over directly) */
+ if (field == 0)
+ date_field_edit(buf, cap, &field_pos, ch, &field_fresh);
+ else
+ field_edit(buf, cap, &field_pos, ch, &field_fresh);
+ normalize_rows(rows, &nrows, &field);
+ nfields = 3 + 4 * nrows;
+ }
+}
+
+static void vouchers_screen(struct app *a)
+{
+ char **items = NULL;
+ int64_t *ids = NULL;
+ size_t n = 0;
+ int fetch = 1;
+ for (;;) {
+ if (g_quit) {
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ return;
+ }
+ if (fetch) {
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ items = NULL;
+ ids = NULL;
+ n = 0;
+ fetch = 0;
+ char largs[96];
+ snprintf(largs, sizeof largs,
+ "{\"limit\":200,\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ char *resp = client_rpc(a->fd, "voucher.list", a->session, a->org,
+ largs);
+ if (!resp || !client_ok(resp)) {
+ show_error("Verifikat", resp);
+ free(resp);
+ return;
+ }
+ n = jarr_size(resp, "result.items");
+ items = xcalloc(n + 1, sizeof(char *));
+ ids = xcalloc(n ? n : 1, sizeof(int64_t));
+ char **idstr = xcalloc(n ? n : 1, sizeof(char *));
+ int idw = 4;
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ ids[i] = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.series", i);
+ char *ser = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.number", i);
+ int64_t number = jint_val(resp, path, 0);
+ char idbuf[32];
+ snprintf(idbuf, sizeof idbuf, "%s%lld", ser ? ser : "",
+ (long long)number);
+ idstr[i] = xstrdup(idbuf);
+ int l = (int)strlen(idbuf);
+ if (l > idw)
+ idw = l;
+ free(ser);
+ }
+ for (size_t i = 0; i < n; i++) {
+ char path[64], line[512];
+ snprintf(path, sizeof path, "result.items.%zu.date", i);
+ char *date = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.description", i);
+ char *desc = jstr_dup(resp, path);
+ snprintf(line, sizeof line, "%s %-*s %s", date ? date : "",
+ idw, idstr[i], desc ? desc : "");
+ items[i] = xstrdup(line);
+ free(date);
+ free(desc);
+ free(idstr[i]);
+ }
+ free(idstr);
+ free(resp);
+ items[n] = xstrdup("+ Nytt verifikat (Ctrl+N)");
+ }
+ size_t total = n + 1;
+ int start = 0;
+ if (a->voucher_sel) {
+ for (size_t i = 0; i < n; i++)
+ if (ids[i] == a->voucher_sel) {
+ start = (int)i;
+ break;
+ }
+ }
+ int cur = start;
+ int sel = select_list("Verifikat", items, (int)total, start, 1, &cur,
+ 1);
+ if (cur >= 0 && (size_t)cur < n)
+ a->voucher_sel = ids[cur];
+ if (sel == -2) {
+ fetch = 1;
+ continue;
+ }
+ int64_t open_id = 0;
+ int want_new = (sel == -4);
+ if (sel >= 0) {
+ if ((size_t)sel == n)
+ want_new = 1;
+ else if ((size_t)sel < n)
+ open_id = ids[sel];
+ } else if (sel != -4) {
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ return;
+ }
+ if (want_new) {
+ open_id = vouchers_new(a);
+ if (open_id <= 0)
+ continue;
+ a->voucher_sel = open_id;
+ fetch = 1;
+ }
+ for (;;) {
+ int64_t nv = 0;
+ int act = voucher_detail(a, open_id, &nv);
+ if (act == 2) {
+ open_id = nv;
+ a->voucher_sel = nv;
+ fetch = 1;
+ continue;
+ }
+ if (act == 1)
+ fetch = 1;
+ break;
+ }
+ }
+}
+
+static const char *type_sv(const char *t)
+{
+ if (!t)
+ return "";
+ if (strcmp(t, "asset") == 0)
+ return "Tillgång";
+ if (strcmp(t, "liability") == 0)
+ return "Skuld";
+ if (strcmp(t, "equity") == 0)
+ return "Eget kapital";
+ if (strcmp(t, "revenue") == 0)
+ return "Intäkt";
+ if (strcmp(t, "expense") == 0)
+ return "Kostnad";
+ return t;
+}
+
+/* Moms treatment for an account: the stored vat_code when set, otherwise
+ derived from the BAS account name (which carries the rate). Accounts
+ whose treatment depends on the transaction stay blank. */
+static const char *vat_display(const char *name, const char *vat_code,
+ char *buf, size_t cap)
+{
+ if (vat_code && *vat_code) {
+ snprintf(buf, cap, "%s", vat_code);
+ return buf;
+ }
+ if (!name || !*name)
+ return "";
+ int pct = 0;
+ for (const char *p = name; *p;) {
+ if (*p >= '0' && *p <= '9') {
+ const char *q = p;
+ int v = 0;
+ while (*q >= '0' && *q <= '9') {
+ v = v * 10 + (*q - '0');
+ q++;
+ }
+ while (*q == ' ')
+ q++;
+ if (*q == '%') {
+ pct = v;
+ break;
+ }
+ p = q;
+ continue;
+ }
+ p++;
+ }
+ int in = strstr(name, "Ingående moms") != NULL;
+ int out = strstr(name, "Utgående moms") != NULL;
+ int reverse = strstr(name, "omvänd") != NULL;
+ if (in || out) {
+ if (pct)
+ snprintf(buf, cap, "%s %d%%", in ? "Ing" : "Utg", pct);
+ else
+ snprintf(buf, cap, "%s", in ? "Ing" : "Utg");
+ return buf;
+ }
+ if (pct) {
+ snprintf(buf, cap, "%d%%%s", pct, reverse ? " omv" : "");
+ return buf;
+ }
+ if (strstr(name, "momsfri") || strstr(name, "momsfritt") ||
+ strstr(name, "momsefri"))
+ return "Momsfri";
+ if (strstr(name, "Redovisningskonto för moms"))
+ return "Avräkning";
+ if (reverse)
+ return "Omvänd";
+ if (strstr(name, "moms"))
+ return "Moms";
+ return "";
+}
+
+/* Kontolista: every account with the details that apply to it. */
+static void accounts_report(struct app *a)
+{
+ for (;;) {
+ char *resp = client_rpc(a->fd, "account.list", a->session, a->org,
+ "{\"active_only\":false}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Kontolista", resp);
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ int name_w = COLS - 45;
+ if (name_w < 12)
+ name_w = 12;
+ struct buf text;
+ buf_init(&text);
+ char line[2048];
+ int hdr = snprintf(line, sizeof line,
+ "%-6s %-*s %-12s %-5s %-6s %-8s\n", "Konto",
+ name_w, "Namn", "Typ", "Aktiv", "SRU", "Moms");
+ buf_append(&text, line, (size_t)hdr);
+ int sep_w = 6 + 1 + name_w + 1 + 12 + 1 + 5 + 1 + 6 + 1 + 8;
+ for (int i = 0; i < sep_w && i < (int)sizeof line - 1; i++)
+ line[i] = '-';
+ line[sep_w < (int)sizeof line ? sep_w : (int)sizeof line - 1] = '\n';
+ buf_append(&text, line, (size_t)(sep_w < (int)sizeof line ? sep_w + 1
+ : (int)sizeof line));
+ int64_t active_count = 0;
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.number", i);
+ char *number = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *name = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.type", i);
+ char *type = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.active", i);
+ int active = jbool_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.sru_code", i);
+ char *sru = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.vat_code", i);
+ char *vat = jstr_dup(resp, path);
+ char ncol[512];
+ char vatbuf[32];
+ snprintf(ncol, sizeof ncol, "%s", name ? name : "");
+ pad_field(ncol, sizeof ncol, name_w);
+ snprintf(line, sizeof line, "%-6s %s %-12s %-5s %-6s %-8s\n",
+ number ? number : "", ncol, type_sv(type),
+ active ? "ja" : "nej", sru ? sru : "",
+ vat_display(name, vat, vatbuf, sizeof vatbuf));
+ buf_append(&text, line, strlen(line));
+ if (active)
+ active_count++;
+ free(number);
+ free(name);
+ free(type);
+ free(sru);
+ free(vat);
+ }
+ snprintf(line, sizeof line, "\n%lld konton, varav %lld aktiva.\n",
+ (long long)n, (long long)active_count);
+ buf_append(&text, line, strlen(line));
+ buf_append(&text, "\0", 1);
+ int again = text_view("Kontolista", (char *)text.p);
+ buf_free(&text);
+ free(resp);
+ if (!again)
+ return;
+ }
+}
+
+static void reports_screen(struct app *a)
+{
+ static const char *const report_items[] = {
+ "Saldobalans (trial balance)",
+ "Resultaträkning",
+ "Balansräkning",
+ "Momsdeklaration",
+ "Kontolista (alla konton)",
+ };
+ for (;;) {
+ if (g_quit)
+ return;
+ int sel = menu("Rapporter", report_items, 5, 0);
+ if (sel < 0)
+ return;
+ if (sel == 4) {
+ accounts_report(a);
+ continue;
+ }
+ char args[256];
+ const char *cmd = NULL;
+ switch (sel) {
+ case 0:
+ cmd = "report.trial_balance";
+ snprintf(args, sizeof args,
+ "{\"include_zero\":false,\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ break;
+ case 1:
+ cmd = "report.income_statement";
+ snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ break;
+ case 2:
+ cmd = "report.balance_sheet";
+ snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ break;
+ case 3: {
+ cmd = "report.vat";
+ char *from = date_prompt("Från (YYYY-MM-DD): ", a->fy_start);
+ if (!from)
+ continue;
+ char f[16];
+ snprintf(f, sizeof f, "%s", from);
+ char *to = date_prompt("Till (YYYY-MM-DD): ", a->fy_end);
+ if (!to)
+ continue;
+ snprintf(args, sizeof args,
+ "{\"from\":\"%s\",\"to\":\"%s\"}", f, to);
+ break;
+ }
+ }
+ for (;;) {
+ char *resp = client_rpc(a->fd, cmd, a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Rapport", resp);
+ free(resp);
+ break;
+ }
+ yyjson_doc *d = parse(resp);
+ yyjson_val *res =
+ d ? jget(yyjson_doc_get_root(d), "result") : NULL;
+ char *pretty =
+ res ? yyjson_val_write(res, YYJSON_WRITE_PRETTY, NULL) : NULL;
+ int again = text_view("Rapport", pretty ? pretty : resp);
+ free(pretty);
+ yyjson_doc_free(d);
+ free(resp);
+ if (!again)
+ break;
+ }
+ }
+}
+
+static void inbox_screen(struct app *a)
+{
+ int top = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp = client_rpc(a->fd, "attachment.list", a->session, a->org,
+ "{\"unlinked\":true,\"limit\":200}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Underlag", resp);
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ char **items = xcalloc(n ? n : 1, sizeof(char *));
+ for (size_t i = 0; i < n; i++) {
+ char path[64], line[512];
+ snprintf(path, sizeof path, "result.items.%zu.filename", i);
+ char *fn = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.size_bytes", i);
+ int64_t size = jint_val(resp, path, 0);
+ snprintf(line, sizeof line, "%-40s %8lld byte", fn ? fn : "",
+ (long long)size);
+ items[i] = xstrdup(line);
+ free(fn);
+ }
+ int view = LINES - 4;
+ if (top > (int)n - view)
+ top = (int)n - view;
+ if (top < 0)
+ top = 0;
+ frame("Underlag (inkorg)");
+ for (size_t i = (size_t)top; i < n && (int)(i - (size_t)top) < view;
+ i++)
+ mvaddnstr(2 + (int)(i - (size_t)top), 2, items[i], COLS - 4);
+ if (n == 0)
+ mvaddstr(2, 2, "Inga obokförda underlag.");
+ hints("a/Ctrl+N = lägg till fil PgUp/PgDn, Home/End rullar F5 ="
+ " uppdatera Esc/q = tillbaka");
+ refresh();
+ int ch = ui_getch();
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(resp);
+ if (ch == 27 || ch == 'q')
+ return;
+ if (ch == KEY_DOWN && top + view < (int)n)
+ top++;
+ else if (ch == KEY_UP && top > 0)
+ top--;
+ else if (ch == KEY_NPAGE)
+ top += view;
+ else if (ch == KEY_PPAGE)
+ top -= view;
+ else if (ch == KEY_HOME)
+ top = 0;
+ else if (ch == KEY_END)
+ top = (int)n > view ? (int)n - view : 0;
+ else if (ch == 'a' || ch == KEY_CTRL_N) {
+ char *path = file_browser(a, a->attachment_dir);
+ if (!path)
+ continue;
+ struct stat sb;
+ if (stat(path, &sb) == 0 &&
+ (long)sb.st_size > a->max_attachment_bytes) {
+ message("Bilaga", "Filen är %.1f MB, max %ld MB.",
+ (double)sb.st_size / (1024 * 1024),
+ a->max_attachment_bytes / (1024 * 1024));
+ free(path);
+ continue;
+ }
+ char *b64 = read_file_b64(path);
+ if (!b64) {
+ message("Fel", "Kunde inte läsa %s", path);
+ continue;
+ }
+ const char *base = strrchr(path, '/');
+ base = base ? base + 1 : path;
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "filename", base);
+ yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ free(b64);
+ size_t reqlen = strlen(args) + 256;
+ char *raw = xmalloc(reqlen);
+ snprintf(raw, reqlen,
+ "{\"v\":1,\"id\":\"tui\",\"cmd\":\"attachment.put\","
+ "\"session\":\"%s\",\"org\":%lld,\"args\":%s}",
+ a->session, (long long)a->org, args);
+ free(args);
+ char *r = NULL;
+ if (client_send_line(a->fd, raw) == 0)
+ r = client_read_line(a->fd);
+ free(raw);
+ if (r && client_ok(r))
+ message("Underlag", "Sparat.");
+ else
+ show_error("Kunde inte spara", r);
+ free(r);
+ }
+ }
+}
+
+static void audit_screen(struct app *a)
+{
+ static const char *const audit_items[] = { "Verifiera hashkedjan",
+ "Senaste händelser" };
+ for (;;) {
+ if (g_quit)
+ return;
+ int sel = menu("Revision", audit_items, 2, 0);
+ if (sel < 0)
+ return;
+ if (sel == 0) {
+ char *resp =
+ client_rpc(a->fd, "audit.verify", a->session, a->org, "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Revision", resp);
+ free(resp);
+ continue;
+ }
+ int64_t checked = jint_val(resp, "result.checked", 0);
+ int is_ok = jbool_val(resp, "result.ok", 0);
+ message("Hashkedja", "%s\n%d händelser kontrollerade.",
+ is_ok ? "Kedjan är intakt." : "KEDJAN ÄR BRUTEN!",
+ (int)checked);
+ free(resp);
+ } else {
+ for (;;) {
+ char *resp = client_rpc(a->fd, "audit.list", a->session,
+ a->org, "{\"limit\":200}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Revision", resp);
+ free(resp);
+ break;
+ }
+ struct buf text;
+ buf_init(&text);
+ size_t n = jarr_size(resp, "result.items");
+ for (size_t i = 0; i < n; i++) {
+ char path[64], line[512];
+ snprintf(path, sizeof path, "result.items.%zu.at", i);
+ char *at = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.action", i);
+ char *action = jstr_dup(resp, path);
+ snprintf(path, sizeof path,
+ "result.items.%zu.result_code", i);
+ char *code = jstr_dup(resp, path);
+ snprintf(path, sizeof path,
+ "result.items.%zu.actor_user_id", i);
+ int64_t actor = jint_val(resp, path, 0);
+ snprintf(line, sizeof line, "%s anv %lld %-22s %s\n",
+ at ? at : "", (long long)actor,
+ action ? action : "", code ? code : "");
+ buf_append(&text, line, strlen(line));
+ free(at);
+ free(action);
+ free(code);
+ }
+ buf_append(&text, "\0", 1);
+ int again = text_view("Behandlingshistorik", (char *)text.p);
+ buf_free(&text);
+ free(resp);
+ if (!again)
+ break;
+ }
+ }
+ }
+}
+
+struct tui_trow {
+ char account[16];
+ char formula[64];
+ char text[64];
+};
+
+static int trow_has_content(const struct tui_trow *r)
+{
+ return r->account[0] || r->formula[0] || r->text[0];
+}
+
+static void trow_normalize(struct tui_trow *rows, int *nrows, int *field)
+{
+ while (*nrows < 64 && trow_has_content(&rows[*nrows - 1])) {
+ memset(&rows[*nrows], 0, sizeof rows[0]);
+ (*nrows)++;
+ }
+ int i = 0;
+ while (i < *nrows - 1) {
+ if (!trow_has_content(&rows[i]) && !trow_has_content(&rows[i + 1])) {
+ int fr = *field >= 3 ? (*field - 3) / 3 : -1;
+ if (fr > i)
+ *field -= 3;
+ memmove(&rows[i], &rows[i + 1],
+ (size_t)(*nrows - i - 1) * sizeof rows[0]);
+ memset(&rows[*nrows - 1], 0, sizeof rows[0]);
+ (*nrows)--;
+ continue;
+ }
+ i++;
+ }
+ if (*nrows < 1) {
+ *nrows = 1;
+ memset(&rows[0], 0, sizeof rows[0]);
+ if (*field >= 3)
+ *field = 3;
+ }
+}
+
+/* Template editor: a form, not a wizard. name == NULL creates a new
+ template, otherwise the named template is loaded and saved via
+ template.update. */
+static int template_form(struct app *a, const char *load_name)
+{
+ static struct tui_trow rows[64];
+ char tname[128] = "", tdesc[256] = "";
+ char series[16];
+ snprintf(series, sizeof series, "%s", a->default_series);
+ int64_t tpl_id = 0;
+ curs_set(1);
+ memset(rows, 0, sizeof rows);
+ int nrows = 1;
+
+ if (load_name && *load_name) {
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "name", load_name);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *resp =
+ client_rpc(a->fd, "template.get", a->session, a->org, args);
+ free(args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Mall", resp);
+ free(resp);
+ curs_set(0);
+ return 0;
+ }
+ tpl_id = jint_val(resp, "result.id", 0);
+ char *nm = jstr_dup(resp, "result.name");
+ char *ser = jstr_dup(resp, "result.series");
+ char *ds = jstr_dup(resp, "result.description");
+ if (nm)
+ snprintf(tname, sizeof tname, "%s", nm);
+ if (ser && *ser)
+ snprintf(series, sizeof series, "%s", ser);
+ if (ds)
+ snprintf(tdesc, sizeof tdesc, "%s", ds);
+ free(nm);
+ free(ser);
+ free(ds);
+ size_t n = jarr_size(resp, "result.rows");
+ if (n > 64)
+ n = 64;
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.rows.%zu.account", i);
+ char *acc = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.formula", i);
+ char *form = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.description", i);
+ char *rd = jstr_dup(resp, path);
+ snprintf(rows[i].account, sizeof rows[i].account, "%s",
+ acc ? acc : "");
+ snprintf(rows[i].formula, sizeof rows[i].formula, "%s",
+ form ? form : "");
+ snprintf(rows[i].text, sizeof rows[i].text, "%s", rd ? rd : "");
+ free(acc);
+ free(form);
+ free(rd);
+ }
+ nrows = n > 0 ? (int)n : 1;
+ free(resp);
+ }
+
+ int field = 0;
+ int field_fresh = 1;
+ size_t field_pos = (size_t)-1;
+ int nfields = 3 + 3 * nrows;
+ const int x_account = 2;
+ const int name_w = 24;
+ const int x_name = 9;
+ const int x_formula = 34;
+ const int form_w = 20;
+ const int x_text = 55;
+ int text_w = COLS - x_text - 2;
+ if (text_w < 8)
+ text_w = 8;
+
+ for (;;) {
+ frame(load_name ? "Redigera mall" : "Ny mall");
+ int caret_y = -1, caret_x = -1;
+ attron(A_BOLD);
+ mvaddstr(2, 2, "Namn");
+ mvaddstr(2, 46, "Serie");
+ mvaddstr(4, 2, "Text");
+ attroff(A_BOLD);
+ {
+ const char *hvals[3] = { tname, series, tdesc };
+ int hx[3] = { 9, 53, 9 };
+ int hy[3] = { 2, 2, 4 };
+ int hw[3] = { 34, 8, COLS - 12 };
+ for (int k = 0; k < 3; k++) {
+ int w = hw[k] > 1 ? hw[k] : 1;
+ char padded[512];
+ snprintf(padded, sizeof padded, "%s", hvals[k]);
+ pad_field(padded, sizeof padded, w);
+ if (field == k) {
+ attron(A_REVERSE);
+ caret_y = hy[k];
+ int cw = disp_width(hvals[k]);
+ caret_x = hx[k] + (cw > w ? w : cw);
+ }
+ mvaddstr(hy[k], hx[k], padded);
+ if (field == k)
+ attroff(A_REVERSE);
+ }
+ }
+ attron(A_BOLD);
+ mvaddstr(6, x_account, "Konto");
+ mvaddstr(6, x_name, "Namn");
+ mvaddstr(6, x_formula, "Formel");
+ mvaddstr(6, x_text, "Radtext");
+ attroff(A_BOLD);
+
+ for (int i = 0; i < nrows; i++) {
+ int y = 7 + i;
+ int sel_ci = -1;
+ if (field >= 3) {
+ int ri = (field - 3) / 3, ci = (field - 3) % 3;
+ if (ri == i)
+ sel_ci = ci;
+ }
+ const char *vals[3] = { rows[i].account, rows[i].formula,
+ rows[i].text };
+ int xs[3] = { x_account, x_formula, x_text };
+ int ws[3] = { 6, form_w, text_w };
+ for (int k = 0; k < 3; k++) {
+ int w = ws[k] > 1 ? ws[k] : 1;
+ char padded[512];
+ snprintf(padded, sizeof padded, "%s", vals[k]);
+ pad_field(padded, sizeof padded, w);
+ if (k == sel_ci) {
+ attron(A_REVERSE);
+ caret_y = y;
+ size_t slen = strlen(vals[k]);
+ size_t cp = (field_fresh || field_pos == (size_t)-1 ||
+ field_pos > slen)
+ ? slen
+ : field_pos;
+ int cw = (int)disp_width_n(vals[k], cp);
+ caret_x = xs[k] + (cw > w ? w : cw);
+ }
+ mvaddstr(y, xs[k], padded);
+ if (k == sel_ci)
+ attroff(A_REVERSE);
+ }
+ {
+ char nbuf[512];
+ const char *nm = acct_name(a, rows[i].account);
+ snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : "");
+ pad_field(nbuf, sizeof nbuf, name_w);
+ attron(A_DIM);
+ mvaddstr(y, x_name, nbuf);
+ attroff(A_DIM);
+ }
+ }
+ move(7 + nrows, 2);
+ clrtoeol();
+ attron(A_DIM);
+ mvaddnstr(7 + nrows, 2,
+ "x = belopp i kronor. Positivt blir debet, negativt kredit.",
+ COLS - 4);
+ attroff(A_DIM);
+ hints("Tab = byta fält F5 = validera F7 = rensa rad F9 = spara Esc = avbryt");
+ if (caret_y >= 0)
+ move(caret_y, caret_x);
+ refresh();
+
+ int ch = ui_getch();
+ if (ch == 27) {
+ curs_set(0);
+ return 0;
+ }
+ if (ch == ' ') {
+ field = (field + 1) % nfields;
+ field_fresh = 1;
+ continue;
+ }
+ if (ch == KEY_BTAB) {
+ field = (field + nfields - 1) % nfields;
+ field_fresh = 1;
+ continue;
+ }
+ if (ch == KEY_UP) {
+ if (field >= 3) {
+ int ci = (field - 3) % 3;
+ int ri = (field - 3) / 3;
+ if (ri > 0) {
+ field = 3 + (ri - 1) * 3 + ci;
+ field_fresh = 1;
+ }
+ }
+ continue;
+ }
+ if (ch == KEY_DOWN) {
+ if (field >= 3) {
+ int ci = (field - 3) % 3;
+ int ri = (field - 3) / 3;
+ if (ri + 1 < nrows) {
+ field = 3 + (ri + 1) * 3 + ci;
+ field_fresh = 1;
+ }
+ }
+ continue;
+ }
+ if (ch == KEY_F(7)) {
+ if (field >= 3) {
+ int ri = (field - 3) / 3;
+ if (ri < nrows) {
+ memset(&rows[ri], 0, sizeof rows[ri]);
+ trow_normalize(rows, &nrows, &field);
+ nfields = 3 + 3 * nrows;
+ field_fresh = 1;
+ }
+ }
+ continue;
+ }
+
+ /* validation and save */
+ if (ch == KEY_F(5) || ch == KEY_F(9)) {
+ const char *problem = NULL;
+ char msg[256] = "";
+ if (!tname[0]) {
+ problem = "Namn saknas.";
+ }
+ int used = 0;
+ for (int i = 0; i < nrows && !problem; i++) {
+ if (!rows[i].account[0])
+ continue;
+ if (!acct_exists(a, rows[i].account)) {
+ snprintf(msg, sizeof msg,
+ "Konto %.15s finns inte (rad %d).",
+ rows[i].account, i + 1);
+ problem = msg;
+ break;
+ }
+ if (!rows[i].formula[0] || !formula_valid(rows[i].formula)) {
+ snprintf(msg, sizeof msg,
+ "Ogiltig formel på rad %d: '%.60s'.", i + 1,
+ rows[i].formula);
+ problem = msg;
+ break;
+ }
+ used++;
+ }
+ if (!problem && used == 0)
+ problem = "Mallen behöver minst en rad.";
+ if (problem) {
+ message("Mall", "%s", problem);
+ continue;
+ }
+
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ if (tpl_id)
+ yyjson_mut_obj_add_int(d, o, "id", tpl_id);
+ yyjson_mut_obj_add_strcpy(d, o, "name", tname);
+ yyjson_mut_obj_add_strcpy(d, o, "series", series);
+ yyjson_mut_obj_add_strcpy(d, o, "description", tdesc);
+ yyjson_mut_val *arr = yyjson_mut_arr(d);
+ for (int i = 0; i < nrows; i++) {
+ if (!rows[i].account[0])
+ continue;
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
+ yyjson_mut_obj_add_strcpy(d, ro, "account", rows[i].account);
+ yyjson_mut_obj_add_strcpy(d, ro, "formula", rows[i].formula);
+ if (rows[i].text[0])
+ yyjson_mut_obj_add_strcpy(d, ro, "description",
+ rows[i].text);
+ }
+ yyjson_mut_obj_add_val(d, o, "rows", arr);
+ if (ch == KEY_F(5))
+ yyjson_mut_obj_add_bool(d, o, "dry_run", true);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ const char *cmd = tpl_id ? "template.update" : "template.create";
+ char *resp = client_rpc(a->fd, cmd, a->session, a->org, args);
+ free(args);
+ if (resp && client_ok(resp)) {
+ if (ch == KEY_F(5)) {
+ message("Validering OK",
+ "Mallen är korrekt (%d rader).", used);
+ } else {
+ message("Mall", "Mallen '%s' sparad.", tname);
+ free(resp);
+ curs_set(0);
+ return 1;
+ }
+ } else {
+ show_error("Kunde inte spara mallen", resp);
+ }
+ free(resp);
+ continue;
+ }
+
+ char *buf = NULL;
+ size_t cap = 0;
+ if (field == 0) {
+ buf = tname;
+ cap = sizeof tname;
+ } else if (field == 1) {
+ buf = series;
+ cap = sizeof series;
+ } else if (field == 2) {
+ buf = tdesc;
+ cap = sizeof tdesc;
+ } else {
+ int ri = (field - 3) / 3, ci = (field - 3) % 3;
+ if (ri < nrows) {
+ switch (ci) {
+ case 0:
+ buf = rows[ri].account;
+ cap = sizeof rows[ri].account;
+ break;
+ case 1:
+ buf = rows[ri].formula;
+ cap = sizeof rows[ri].formula;
+ break;
+ default:
+ buf = rows[ri].text;
+ cap = sizeof rows[ri].text;
+ break;
+ }
+ }
+ }
+ if (!buf)
+ continue;
+ field_edit(buf, cap, &field_pos, ch, &field_fresh);
+ trow_normalize(rows, &nrows, &field);
+ nfields = 3 + 3 * nrows;
+ }
+}
+
+static void template_archive_ui(struct app *a)
+{
+ char *name = prompt("Mall att arkivera: ", "", 0);
+ if (!name || !*name)
+ return;
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "name", name);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *resp =
+ client_rpc(a->fd, "template.archive", a->session, a->org, args);
+ free(args);
+ if (resp && client_ok(resp))
+ message("Mall", "Mallen '%s' arkiverad.", name);
+ else
+ show_error("Kunde inte arkivera", resp);
+ free(resp);
+}
+
+/* ------------------------------------------------------------------ */
+/* ingående balans (series IB) */
+/* ------------------------------------------------------------------ */
+
+struct tui_ibrow {
+ char account[16];
+ char amount[32];
+ char text[64];
+};
+
+static int ibrow_has_content(const struct tui_ibrow *r)
+{
+ return r->account[0] || r->amount[0] || r->text[0];
+}
+
+static void ibrow_normalize(struct tui_ibrow *rows, int *nrows, int *field)
+{
+ while (*nrows < 64 && ibrow_has_content(&rows[*nrows - 1])) {
+ memset(&rows[*nrows], 0, sizeof rows[0]);
+ (*nrows)++;
+ }
+ int i = 0;
+ while (i < *nrows - 1) {
+ if (!ibrow_has_content(&rows[i]) && !ibrow_has_content(&rows[i + 1])) {
+ int fr = *field / 3;
+ if (fr > i)
+ *field -= 3;
+ memmove(&rows[i], &rows[i + 1],
+ (size_t)(*nrows - i - 1) * sizeof rows[0]);
+ memset(&rows[*nrows - 1], 0, sizeof rows[0]);
+ (*nrows)--;
+ continue;
+ }
+ i++;
+ }
+ if (*nrows < 1) {
+ *nrows = 1;
+ memset(&rows[0], 0, sizeof rows[0]);
+ if (*field >= 3)
+ *field = 0;
+ }
+}
+
+/* Loads the net IB per account for the active fiscal year. */
+static int ib_load(struct app *a, char ***out_acc, int64_t **out_amt,
+ int *out_n)
+{
+ *out_acc = NULL;
+ *out_amt = NULL;
+ *out_n = 0;
+ char args[128];
+ snprintf(args, sizeof args, "{\"fiscal_year\":%lld,\"series\":\"IB\","
+ "\"limit\":200}",
+ (long long)a->fy);
+ char *resp =
+ client_rpc(a->fd, "voucher.list", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Ingående balans", resp);
+ free(resp);
+ return -1;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ char **accs = xcalloc(n ? n : 1, sizeof(char *));
+ int64_t *amts = xcalloc(n ? n : 1, sizeof(int64_t));
+ int count = 0;
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ int64_t id = jint_val(resp, path, 0);
+ if (id <= 0)
+ continue;
+ char vargs[64];
+ snprintf(vargs, sizeof vargs, "{\"id\":%lld}", (long long)id);
+ char *v = client_rpc(a->fd, "voucher.get", a->session, a->org, vargs);
+ if (!v || !client_ok(v)) {
+ free(v);
+ continue;
+ }
+ size_t rn = jarr_size(v, "result.rows");
+ for (size_t k = 0; k < rn; k++) {
+ char p[64];
+ snprintf(p, sizeof p, "result.rows.%zu.account", k);
+ char *acc = jstr_dup(v, p);
+ snprintf(p, sizeof p, "result.rows.%zu.debit_ore", k);
+ int64_t d = jint_val(v, p, 0);
+ snprintf(p, sizeof p, "result.rows.%zu.credit_ore", k);
+ int64_t c = jint_val(v, p, 0);
+ if (acc) {
+ int found = -1;
+ for (int j = 0; j < count; j++)
+ if (strcmp(accs[j], acc) == 0) {
+ found = j;
+ break;
+ }
+ if (found >= 0) {
+ amts[found] += d - c;
+ } else {
+ accs[count] = acc;
+ amts[count] = d - c;
+ count++;
+ }
+ if (found >= 0)
+ free(acc);
+ }
+ }
+ free(v);
+ }
+ free(resp);
+ *out_acc = accs;
+ *out_amt = amts;
+ *out_n = count;
+ return 0;
+}
+
+static int64_t ib_old_lookup(char **accs, int64_t *amts, int n,
+ const char *account)
+{
+ for (int i = 0; i < n; i++)
+ if (strcmp(accs[i], account) == 0)
+ return amts[i];
+ return 0;
+}
+
+static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold)
+{
+ static struct tui_ibrow rows[64];
+ memset(rows, 0, sizeof rows);
+ for (int i = 0; i < nold && i < 63; i++) {
+ snprintf(rows[i].account, sizeof rows[i].account, "%s", old_acc[i]);
+ kr_format(old_amt[i], rows[i].amount, sizeof rows[i].amount);
+ }
+ int nrows = nold > 0 ? nold : 1;
+ int field = 0;
+ int field_fresh = 1;
+ size_t field_pos = (size_t)-1;
+ int nfields = 3 * nrows;
+ const int x_account = 2;
+ const int name_w = 24;
+ const int x_name = 9;
+ const int x_amount = 34;
+ const int amount_w = 14;
+ const int x_text = 49;
+ int text_w = COLS - x_text - 2;
+ if (text_w < 6)
+ text_w = 6;
+ curs_set(1);
+
+ for (;;) {
+ frame("Ingående balans");
+ int caret_y = -1, caret_x = -1;
+ attron(A_BOLD);
+ mvaddstr(2, 2,
+ "Positiva belopp = debet, negativa = kredit. Summan ska bli"
+ " noll.");
+ attroff(A_BOLD);
+ attron(A_BOLD);
+ mvaddstr(4, x_account, "Konto");
+ mvaddstr(4, x_name, "Namn");
+ mvaddstr(4, x_amount, "Belopp");
+ mvaddstr(4, x_text, "Text");
+ attroff(A_BOLD);
+ for (int i = 0; i < nrows; i++) {
+ int y = 5 + i;
+ int sel_ci = -1;
+ if (field >= 0 && field < nfields) {
+ int ri = field / 3, ci = field % 3;
+ if (ri == i)
+ sel_ci = ci;
+ }
+ const char *vals[3] = { rows[i].account, rows[i].amount,
+ rows[i].text };
+ int xs[3] = { x_account, x_amount, x_text };
+ int ws[3] = { 6, amount_w, text_w };
+ for (int k = 0; k < 3; k++) {
+ int w = ws[k] > 1 ? ws[k] : 1;
+ char padded[512];
+ snprintf(padded, sizeof padded, "%s", vals[k]);
+ pad_field(padded, sizeof padded, w);
+ if (k == sel_ci) {
+ attron(A_REVERSE);
+ caret_y = y;
+ size_t slen = strlen(vals[k]);
+ size_t cp = (field_fresh || field_pos == (size_t)-1 ||
+ field_pos > slen)
+ ? slen
+ : field_pos;
+ int cw = (int)disp_width_n(vals[k], cp);
+ caret_x = xs[k] + (cw > w ? w : cw);
+ }
+ mvaddstr(y, xs[k], padded);
+ if (k == sel_ci)
+ attroff(A_REVERSE);
+ }
+ {
+ char nbuf[512];
+ const char *nm = acct_name(a, rows[i].account);
+ snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : "");
+ pad_field(nbuf, sizeof nbuf, name_w);
+ attron(A_DIM);
+ mvaddstr(y, x_name, nbuf);
+ attroff(A_DIM);
+ }
+ }
+ hints("Tab = byta fält F5 = validera F7 = rensa rad F9 = spara Esc = avbryt");
+ if (caret_y >= 0)
+ move(caret_y, caret_x);
+ refresh();
+
+ int ch = ui_getch();
+ if (ch == 27) {
+ curs_set(0);
+ return 0;
+ }
+ if (ch == '\t') {
+ field = (field + 1) % nfields;
+ field_fresh = 1;
+ continue;
+ }
+ if (ch == KEY_BTAB) {
+ field = (field + nfields - 1) % nfields;
+ field_fresh = 1;
+ continue;
+ }
+ if (ch == KEY_UP) {
+ int ci = field % 3, ri = field / 3;
+ if (ri > 0) {
+ field = (ri - 1) * 3 + ci;
+ field_fresh = 1;
+ }
+ continue;
+ }
+ if (ch == KEY_DOWN) {
+ int ci = field % 3, ri = field / 3;
+ if (ri + 1 < nrows) {
+ field = (ri + 1) * 3 + ci;
+ field_fresh = 1;
+ }
+ continue;
+ }
+ if (ch == KEY_F(7)) {
+ int ri = field / 3;
+ if (ri < nrows) {
+ memset(&rows[ri], 0, sizeof rows[ri]);
+ ibrow_normalize(rows, &nrows, &field);
+ nfields = 3 * nrows;
+ field_fresh = 1;
+ }
+ continue;
+ }
+
+ if (ch == KEY_F(5) || ch == KEY_F(9)) {
+ const char *problem = NULL;
+ char msg[256] = "";
+ int64_t sum = 0;
+ int used = 0;
+ for (int i = 0; i < nrows && !problem; i++) {
+ if (!rows[i].account[0])
+ continue;
+ if (!acct_exists(a, rows[i].account)) {
+ snprintf(msg, sizeof msg,
+ "Konto %.15s finns inte (rad %d).",
+ rows[i].account, i + 1);
+ problem = msg;
+ break;
+ }
+ double kr = 0;
+ if (rows[i].amount[0] && !parse_x_double(rows[i].amount, &kr)) {
+ snprintf(msg, sizeof msg,
+ "Ogiltigt belopp på rad %d: '%.20s'.", i + 1,
+ rows[i].amount);
+ problem = msg;
+ break;
+ }
+ sum += (int64_t)llround(kr * 100.0);
+ used++;
+ }
+ if (!problem && used == 0)
+ problem = "Ange minst ett konto.";
+ if (!problem && sum != 0) {
+ snprintf(msg, sizeof msg,
+ "Summan måste bli noll (nu %lld öre).",
+ (long long)sum);
+ problem = msg;
+ }
+ if (problem) {
+ message("Ingående balans", "%s", problem);
+ continue;
+ }
+
+ if (ch == KEY_F(5)) {
+ message("Validering OK", "%d konton, summan är noll.", used);
+ continue;
+ }
+
+ /* delta against the existing IB so nothing is ever edited */
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ char datebuf[16];
+ snprintf(datebuf, sizeof datebuf, "%s", a->fy_start);
+ yyjson_mut_obj_add_strcpy(d, o, "date", datebuf);
+ yyjson_mut_obj_add_strcpy(d, o, "description",
+ "Ingående balans");
+ yyjson_mut_obj_add_strcpy(d, o, "series", "IB");
+ char *ref = util_random_id("tui-", 8);
+ yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref);
+ free(ref);
+ yyjson_mut_val *arr = yyjson_mut_arr(d);
+ int posted = 0;
+ for (int i = 0; i < nrows; i++) {
+ if (!rows[i].account[0])
+ continue;
+ double kr = 0;
+ if (rows[i].amount[0] && !parse_x_double(rows[i].amount, &kr))
+ kr = 0;
+ int64_t new_amt = (int64_t)llround(kr * 100.0);
+ int64_t prev_amt = ib_old_lookup(old_acc, old_amt, nold,
+ rows[i].account);
+ int64_t delta = new_amt - prev_amt;
+ if (delta == 0)
+ continue;
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
+ yyjson_mut_obj_add_strcpy(d, ro, "account", rows[i].account);
+ yyjson_mut_obj_add_int(d, ro, "debit_ore",
+ delta > 0 ? delta : 0);
+ yyjson_mut_obj_add_int(d, ro, "credit_ore",
+ delta < 0 ? -delta : 0);
+ if (rows[i].text[0])
+ yyjson_mut_obj_add_strcpy(d, ro, "description",
+ rows[i].text);
+ posted++;
+ }
+ /* accounts that were in the old IB but removed now */
+ for (int i = 0; i < nold; i++) {
+ int still = 0;
+ for (int j = 0; j < nrows; j++)
+ if (strcmp(rows[j].account, old_acc[i]) == 0) {
+ still = 1;
+ break;
+ }
+ if (!still && old_amt[i] != 0) {
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
+ yyjson_mut_obj_add_strcpy(d, ro, "account", old_acc[i]);
+ yyjson_mut_obj_add_int(d, ro, "debit_ore",
+ old_amt[i] < 0 ? -old_amt[i] : 0);
+ yyjson_mut_obj_add_int(d, ro, "credit_ore",
+ old_amt[i] > 0 ? old_amt[i] : 0);
+ posted++;
+ }
+ }
+ if (posted == 0) {
+ yyjson_mut_doc_free(d);
+ message("Ingående balans", "Ingen ändring.");
+ continue;
+ }
+ yyjson_mut_obj_add_val(d, o, "rows", arr);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *resp =
+ client_rpc(a->fd, "voucher.post", a->session, a->org, args);
+ free(args);
+ if (resp && client_ok(resp)) {
+ int64_t num = jint_val(resp, "result.number", 0);
+ message("Ingående balans",
+ "Sparat som IB %lld (%d justerade rader).",
+ (long long)num, posted);
+ free(resp);
+ curs_set(0);
+ return 1;
+ }
+ show_error("Kunde inte spara ingående balans", resp);
+ free(resp);
+ continue;
+ }
+
+ char *buf = NULL;
+ size_t cap = 0;
+ int ri = field / 3, ci = field % 3;
+ if (ri < nrows) {
+ switch (ci) {
+ case 0:
+ buf = rows[ri].account;
+ cap = sizeof rows[ri].account;
+ break;
+ case 1:
+ buf = rows[ri].amount;
+ cap = sizeof rows[ri].amount;
+ break;
+ default:
+ buf = rows[ri].text;
+ cap = sizeof rows[ri].text;
+ break;
+ }
+ }
+ if (!buf)
+ continue;
+ field_edit(buf, cap, &field_pos, ch, &field_fresh);
+ ibrow_normalize(rows, &nrows, &field);
+ nfields = 3 * nrows;
+ }
+}
+
+static void ib_screen(struct app *a)
+{
+ char **accs = NULL;
+ int64_t *amts = NULL;
+ int n = 0;
+ int reload = 1;
+ int top = 0;
+ for (;;) {
+ if (reload) {
+ for (int i = 0; i < n; i++)
+ free(accs[i]);
+ free(accs);
+ free(amts);
+ accs = NULL;
+ amts = NULL;
+ n = 0;
+ top = 0;
+ reload = 0;
+ if (ib_load(a, &accs, &amts, &n) != 0)
+ return;
+ }
+ int view = LINES - 10;
+ if (view < 1)
+ view = 1;
+ if (top > n - view)
+ top = n - view;
+ if (top < 0)
+ top = 0;
+ frame("Ingående balans");
+ attron(A_BOLD);
+ mvprintw(2, 2, "Räkenskapsår %s serie IB", a->fy_label);
+ mvaddstr(4, 2, "Konto");
+ mvaddstr(4, 9, "Namn");
+ mvaddstr(4, 62, "Belopp");
+ attroff(A_BOLD);
+ int64_t sum = 0;
+ if (n == 0) {
+ mvaddstr(6, 2, "Ingen ingående balans registrerad ännu.");
+ }
+ for (int i = 0; i < view && top + i < n; i++) {
+ int idx = top + i;
+ char amount[32];
+ kr_format(amts[idx], amount, sizeof amount);
+ const char *nm = acct_name(a, accs[idx]);
+ char nbuf[128];
+ snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : "");
+ pad_field(nbuf, sizeof nbuf, 50);
+ mvprintw(6 + i, 2, "%-6s %s %14s", accs[idx], nbuf, amount);
+ sum += amts[idx];
+ }
+ char sumbuf[32];
+ kr_format(sum, sumbuf, sizeof sumbuf);
+ attron(A_BOLD);
+ mvprintw(LINES - 4, 2, "Summa: %s", sumbuf);
+ attroff(A_BOLD);
+ hints("e/Ctrl+N = redigera PgUp/PgDn, Home/End rullar F5 ="
+ " uppdatera Esc/q = tillbaka");
+ refresh();
+ int ch = ui_getch();
+ if (ch == 'e' || ch == 'E' || ch == KEY_CTRL_N) {
+ if (ib_form(a, accs, amts, n))
+ reload = 1;
+ continue;
+ }
+ if (ch == KEY_DOWN && top + view < n)
+ top++;
+ else if (ch == KEY_UP && top > 0)
+ top--;
+ else if (ch == KEY_NPAGE)
+ top += view;
+ else if (ch == KEY_PPAGE)
+ top -= view;
+ else if (ch == KEY_HOME)
+ top = 0;
+ else if (ch == KEY_END)
+ top = n > view ? n - view : 0;
+ else if (ch == KEY_F(5)) {
+ reload = 1;
+ continue;
+ } else if (ch == 27 || ch == 'q') {
+ for (int i = 0; i < n; i++)
+ free(accs[i]);
+ free(accs);
+ free(amts);
+ return;
+ }
+ }
+}
+
+static const char *const SETTING_KEYS[] = { "default_series",
+ "attachment_dir" };
+static const char *const SETTING_LABELS[] = { "Standardserie",
+ "Bilagornas mapp" };
+
+static void settings_screen(struct app *a)
+{
+ const int nset = (int)(sizeof SETTING_KEYS / sizeof SETTING_KEYS[0]);
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp =
+ client_rpc(a->fd, "settings.get", a->session, a->org, "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Inställningar", resp);
+ free(resp);
+ return;
+ }
+ char **vals = xcalloc(nset, sizeof(char *));
+ for (int i = 0; i < nset; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.%s", SETTING_KEYS[i]);
+ char *v = jstr_dup(resp, path);
+ vals[i] = v ? v : xstrdup("");
+ }
+ free(resp);
+
+ int sel = 0;
+ for (;;) {
+ frame("Inställningar");
+ attron(A_BOLD);
+ mvaddstr(4, 2, "Inställning");
+ mvaddstr(4, 29, "Värde");
+ attroff(A_BOLD);
+ for (int i = 0; i < nset; i++) {
+ char lbl[128], line[512];
+ snprintf(lbl, sizeof lbl, "%s", SETTING_LABELS[i]);
+ pad_field(lbl, sizeof lbl, 26);
+ snprintf(line, sizeof line, "%s %s", lbl, vals[i]);
+ if (i == sel)
+ attron(A_REVERSE);
+ mvaddnstr(6 + i, 2, line, COLS - 4);
+ if (i == sel)
+ attroff(A_REVERSE);
+ }
+ hints("upp/ned Enter = ändra F5 = uppdatera Esc/q = tillbaka"
+ " Ctrl+C = avsluta");
+ refresh();
+ int ch = ui_getch();
+ if (ch == KEY_UP && sel > 0)
+ sel--;
+ else if (ch == KEY_DOWN && sel < nset - 1)
+ sel++;
+ else if (ch == KEY_F(5))
+ break; /* reload */
+ else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
+ char label[128];
+ snprintf(label, sizeof label, "%s: ", SETTING_LABELS[sel]);
+ char *val = prompt(label, vals[sel], 0);
+ if (val) {
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "key", SETTING_KEYS[sel]);
+ yyjson_mut_obj_add_strcpy(d, o, "value", val);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *r = client_rpc(a->fd, "settings.set", a->session,
+ a->org, args);
+ free(args);
+ if (r && client_ok(r)) {
+ char *nv = jstr_dup(r, "result.value");
+ if (nv) {
+ free(vals[sel]);
+ vals[sel] = nv;
+ if (strcmp(SETTING_KEYS[sel],
+ "default_series") == 0)
+ snprintf(a->default_series,
+ sizeof a->default_series, "%s", nv);
+ else if (strcmp(SETTING_KEYS[sel],
+ "attachment_dir") == 0)
+ snprintf(a->attachment_dir,
+ sizeof a->attachment_dir, "%s", nv);
+ }
+ } else {
+ show_error("Kunde inte spara inställningen", r);
+ }
+ free(r);
+ }
+ } else if (ch == 27 || ch == 'q') {
+ for (int i = 0; i < nset; i++)
+ free(vals[i]);
+ free(vals);
+ return;
+ }
+ }
+ for (int i = 0; i < nset; i++)
+ free(vals[i]);
+ free(vals);
+ }
+}
+
+static void templates_screen(struct app *a)
+{
+ static const char *const items[] = { "Mallar (lista/redigera)", "Ny mall",
+ "Arkivera mall" };
+ for (;;) {
+ if (g_quit)
+ return;
+ int sel = menu("Mallar", items, 3, 1);
+ if (sel == -4) {
+ template_form(a, NULL);
+ continue;
+ }
+ if (sel < 0)
+ return;
+ if (sel == 1) {
+ template_form(a, NULL);
+ continue;
+ }
+ if (sel == 2) {
+ template_archive_ui(a);
+ continue;
+ }
+ for (;;) {
+ char *resp = client_rpc(a->fd, "template.list", a->session,
+ a->org, "{\"active_only\":true}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Mallar", resp);
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ if (n == 0) {
+ message("Mallar", "Inga mallar ännu.");
+ free(resp);
+ break;
+ }
+ char **names = xcalloc(n, sizeof(char *));
+ char **lines = xcalloc(n, sizeof(char *));
+ for (size_t i = 0; i < n; i++) {
+ char path[64], line[512];
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *nm = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.series", i);
+ char *ser = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.row_count", i);
+ int64_t rc = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.description", i);
+ char *ds = jstr_dup(resp, path);
+ snprintf(line, sizeof line, "%-24s %-3s %2lld rader %s",
+ nm ? nm : "", ser ? ser : "", (long long)rc,
+ ds ? ds : "");
+ names[i] = xstrdup(nm ? nm : "");
+ lines[i] = xstrdup(line);
+ free(nm);
+ free(ser);
+ free(ds);
+ }
+ int s = select_list("Mallar", lines, (int)n, 0, 1, NULL, 0);
+ if (s >= 0)
+ template_form(a, names[s]);
+ for (size_t i = 0; i < n; i++) {
+ free(names[i]);
+ free(lines[i]);
+ }
+ free(names);
+ free(lines);
+ free(resp);
+ if (s == -2)
+ continue;
+ break;
+ }
+ }
+}
+
+static const char *const MAIN_ITEMS[] = {
+ "Verifikat", "Underlag (inkorg)", "Ingående balans",
+ "Mallar", "Rapporter", "Revision",
+ "Inställningar", "Byt räkenskapsår", "Logga ut / avsluta",
+};
+
+static void dashboard(struct app *a)
+{
+ for (;;) {
+ int sel = menu("Bokf", MAIN_ITEMS, 9, 1);
+ if (g_quit)
+ return;
+ if (sel == -4) {
+ vouchers_new(a);
+ continue;
+ }
+ if (sel == -5)
+ return;
+ if (sel < 0)
+ continue; /* Esc/back at the top level never exits */
+ switch (sel) {
+ case 0:
+ vouchers_screen(a);
+ break;
+ case 1:
+ inbox_screen(a);
+ break;
+ case 2:
+ ib_screen(a);
+ break;
+ case 3:
+ templates_screen(a);
+ break;
+ case 4:
+ reports_screen(a);
+ break;
+ case 5:
+ audit_screen(a);
+ break;
+ case 6:
+ settings_screen(a);
+ break;
+ case 7:
+ select_fiscal_year(a);
+ break;
+ case 8:
+ return;
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* login */
+/* ------------------------------------------------------------------ */
+
+static int login_screen(struct app *a)
+{
+ char socket_path[256], user[64], pass[128];
+ snprintf(socket_path, sizeof socket_path, "%s", a->socket);
+ const char *env_user = getenv("BOKFD_USER");
+ const char *env_pass = getenv("BOKFD_PASSWORD");
+ snprintf(user, sizeof user, "%s", env_user ? env_user : "");
+ snprintf(pass, sizeof pass, "%s", env_pass ? env_pass : "");
+ int field = 3; /* 0 server, 1 user, 2 password, 3 = Logga in button */
+
+ for (;;) {
+ frame("bokf — inloggning");
+ attron(A_BOLD);
+ mvaddstr(4, 4, "Server");
+ mvaddstr(6, 4, "Användare");
+ mvaddstr(8, 4, "Lösenord");
+ attroff(A_BOLD);
+ mvaddstr(4, 16, socket_path);
+ mvaddstr(6, 16, user);
+ {
+ char stars[128];
+ size_t n = strlen(pass);
+ if (n > sizeof stars - 1)
+ n = sizeof stars - 1;
+ memset(stars, '*', n);
+ stars[n] = '\0';
+ mvaddstr(8, 16, stars);
+ }
+ {
+ const char *btn = " Logga in ";
+ int bw = disp_width(btn);
+ int bx = (COLS - bw) / 2;
+ if (bx < 2)
+ bx = 2;
+ if (field == 3) {
+ attron(A_REVERSE | A_BOLD);
+ curs_set(0);
+ }
+ mvaddstr(11, bx, btn);
+ if (field == 3)
+ attroff(A_REVERSE | A_BOLD);
+ }
+ hints("Tab = byta fält/knapp Enter = logga in Ctrl+C = avsluta");
+ refresh();
+
+ if (field == 3) {
+ int ch = ui_getch();
+ if (ch == 27) {
+ if (g_quit) {
+ endwin();
+ return -1;
+ }
+ continue; /* Esc never exits here */
+ }
+ if (ch == '\t') {
+ field = 0;
+ curs_set(1);
+ continue;
+ }
+ if (ch == KEY_BTAB) {
+ field = 2;
+ curs_set(1);
+ continue;
+ }
+ if (ch != '\n' && ch != '\r' && ch != KEY_ENTER && ch != ' ')
+ continue;
+ /* fall through to the login attempt */
+ } else {
+ int y = 4 + field * 2;
+ int rc = edit_field(y, 16, field == 0 ? socket_path
+ : (field == 1 ? user : pass),
+ field == 0 ? sizeof socket_path
+ : (field == 1 ? sizeof user
+ : sizeof pass),
+ field == 2);
+ if (rc == 0) {
+ if (g_quit) {
+ endwin();
+ return -1;
+ }
+ continue;
+ }
+ if (rc == 2) {
+ field = (field + 1) % 4;
+ continue;
+ }
+ if (rc == 3) {
+ field = (field + 3) % 4;
+ continue;
+ }
+ if (field < 2) {
+ field++;
+ continue;
+ }
+ /* field 2: Enter submits */
+ }
+
+ /* attempt login */
+ snprintf(a->socket, sizeof a->socket, "%s", socket_path);
+ a->fd = client_connect(a->socket);
+ if (a->fd < 0) {
+ message("Fel", "Kunde inte ansluta till %s", a->socket);
+ continue;
+ }
+ char *err = NULL, *session = NULL;
+ if (client_login(a->fd, user, pass, &session, &err) != 0) {
+ char *code = jstr_dup(err, "error.code");
+ char *msg = jstr_dup(err, "error.message");
+ message("Inloggning misslyckades", "%s: %s",
+ code ? code : "fel", msg ? msg : "okänt fel");
+ free(code);
+ free(msg);
+ free(err);
+ close(a->fd);
+ a->fd = -1;
+ continue;
+ }
+ snprintf(a->session, sizeof a->session, "%s", session);
+ snprintf(a->username, sizeof a->username, "%s", user);
+ free(session);
+ return 0;
+ }
+}
+
+static int select_org(struct app *a)
+{
+ char *resp = client_rpc(a->fd, "session.list_orgs", a->session, 0, "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Organisationer", resp);
+ free(resp);
+ return -1;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ if (n == 0) {
+ message("Organisationer",
+ "Inga organisationer. Skapa en med bokfctl org.create.");
+ free(resp);
+ return -1;
+ }
+ char **items = xcalloc(n, sizeof(char *));
+ int64_t *ids = xcalloc(n, sizeof(int64_t));
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *name = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.role", i);
+ char *role = jstr_dup(resp, path);
+ char line[256];
+ snprintf(line, sizeof line, "%-30s (%s)", name ? name : "",
+ role ? role : "");
+ items[i] = xstrdup(line);
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ ids[i] = jint_val(resp, path, 0);
+ free(name);
+ free(role);
+ }
+ int sel = select_list("Välj organisation att representera", items, (int)n,
+ 0, 0, NULL, 0);
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(resp);
+ if (sel < 0)
+ return -1;
+ a->org = ids[sel];
+ free(ids);
+ return 0;
+}
+
+static void usage(FILE *f)
+{
+ fprintf(f,
+ "usage: bokftui [options]\n"
+ " --socket TARGET unix socket or tcp:host:port\n"
+ " --user NAME prefill username\n"
+ " --org ID select org directly\n"
+ " --version\n");
+}
+
+int main(int argc, char **argv)
+{
+ struct app app;
+ memset(&app, 0, sizeof app);
+ app.fd = -1;
+ const char *socket = getenv("BOKFD_SOCKET");
+ if (!socket)
+ socket = "/run/bokfd/bokfd.sock";
+ int64_t want_org = 0;
+ for (int i = 1; i < argc; i++) {
+ if (strcmp(argv[i], "--socket") == 0 && i + 1 < argc)
+ socket = argv[++i];
+ else if (strcmp(argv[i], "--user") == 0 && i + 1 < argc) {
+ (void)argv[++i];
+ } else if (strcmp(argv[i], "--org") == 0 && i + 1 < argc) {
+ want_org = strtoll(argv[++i], NULL, 10);
+ } else if (strcmp(argv[i], "--version") == 0) {
+ printf("bokftui %s\n", BOKF_VERSION);
+ return 0;
+ } else if (strcmp(argv[i], "--help") == 0) {
+ usage(stdout);
+ return 0;
+ } else {
+ usage(stderr);
+ return 2;
+ }
+ }
+ snprintf(app.socket, sizeof app.socket, "%s", socket);
+
+ setlocale(LC_ALL, "");
+ initscr();
+ set_escdelay(100);
+ cbreak();
+ noecho();
+ keypad(stdscr, TRUE);
+ curs_set(1);
+
+ if (login_screen(&app) != 0) {
+ endwin();
+ return 0;
+ }
+ curs_set(0);
+ if (want_org > 0) {
+ app.org = want_org;
+ } else if (select_org(&app) != 0) {
+ endwin();
+ return 0;
+ }
+ app_refresh_context(&app);
+ update_status(&app);
+ dashboard(&app);
+
+ client_rpc(app.fd, "session.close", app.session, 0, "{}");
+ acct_cache_free();
+ close(app.fd);
+ endwin();
+ return 0;
+}