#include #include #include #include #include #include #include #include #include #include #include #include #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; /* Ctrl+R re-execs the binary, keeping session, org, year and screen */ static int g_reload = 0; static char g_scene[32] = "dashboard"; static char g_reload_scene[32]; static int ui_getch(void) { int ch = getch(); if (ch == 3) { /* Ctrl+C */ g_quit = 1; return 27; /* behave like "back" so screens unwind */ } if (ch == 18) { /* Ctrl+R */ g_reload = 1; snprintf(g_reload_scene, sizeof g_reload_scene, "%s", g_scene); return 27; } return ch; } struct app { struct client_conn conn; 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) { char line[512]; snprintf(line, sizeof line, "%s ^R = ladda om", s); attron(A_DIM); mvaddstr(LINES - 1, 2, line); 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->conn, "session.use_org", a->session, 0, args); free(resp); resp = client_rpc(&a->conn, "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->conn, "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->conn, "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->conn, "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->conn, "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->conn, "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->conn, "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->conn, "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->conn, "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->conn, "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->conn, "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->conn, "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->conn, "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->conn, raw) == 0) r = client_read_line(&a->conn); 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->conn, raw) == 0) { char *r = client_read_line(&a->conn); 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->conn, "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->conn, "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->conn, "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->conn, 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->conn, "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->conn, raw) == 0) r = client_read_line(&a->conn); 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->conn, "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->conn, "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->conn, "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->conn, 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->conn, "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->conn, "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->conn, "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->conn, "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; } } } struct company_field { const char *key; const char *label; int numeric; }; static const struct company_field COMPANY_FIELDS[] = { { "name", "Företagsnamn", 0 }, { "org_nr", "Organisationsnummer", 0 }, { "vat_nr", "Momsregistreringsnummer", 0 }, { "address", "Adress", 0 }, { "postal_code", "Postnummer", 0 }, { "city", "Ort", 0 }, { "country", "Land", 0 }, { "email", "E-post", 0 }, { "phone", "Telefon", 0 }, { "moms_period", "Momsperiod (month/quarter/year)", 0 }, { "framework", "Regelverk (K2/K3)", 0 }, { "fiscal_year_start_month", "Startmånad räkenskapsår (1-12)", 1 }, }; static int company_field_valid(const struct company_field *f, const char *v) { if (f->numeric) { char *end = NULL; long n = strtol(v, &end, 10); return end && *end == '\0' && n >= 1 && n <= 12; } if (strcmp(f->key, "moms_period") == 0) return strcmp(v, "month") == 0 || strcmp(v, "quarter") == 0 || strcmp(v, "year") == 0; if (strcmp(f->key, "framework") == 0) return strcmp(v, "K2") == 0 || strcmp(v, "K3") == 0; return 1; } static void company_screen(struct app *a) { const int nf = (int)(sizeof COMPANY_FIELDS / sizeof COMPANY_FIELDS[0]); int owner = a->role[0] && strcmp(a->role, "owner") == 0; for (;;) { if (g_quit) return; char *resp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Företagsuppgifter", resp); free(resp); return; } char **vals = xcalloc(nf, sizeof(char *)); for (int i = 0; i < nf; i++) { char path[64]; snprintf(path, sizeof path, "result.%s", COMPANY_FIELDS[i].key); if (COMPANY_FIELDS[i].numeric) { char tmp[32]; snprintf(tmp, sizeof tmp, "%lld", (long long)jint_val(resp, path, 0)); vals[i] = xstrdup(tmp); } else { char *v = jstr_dup(resp, path); vals[i] = v ? v : xstrdup(""); } } free(resp); int sel = 0; int back = 0; for (;;) { frame("Företagsuppgifter"); attron(A_BOLD); mvaddstr(3, 2, "Uppgift"); mvaddstr(3, 34, "Värde"); attroff(A_BOLD); for (int i = 0; i < nf; i++) { char lbl[128], line[512]; snprintf(lbl, sizeof lbl, "%s", COMPANY_FIELDS[i].label); pad_field(lbl, sizeof lbl, 31); snprintf(line, sizeof line, "%s %s", lbl, vals[i]); if (i == sel) { attron(A_REVERSE); mvaddnstr(5 + i, 2, line, COLS - 4); attroff(A_REVERSE); } else { if (!owner) attron(A_DIM); mvaddnstr(5 + i, 2, line, COLS - 4); if (!owner) attroff(A_DIM); } } hints(owner ? "upp/ned Enter = ändra F5 = uppdatera" " Esc/q = tillbaka Ctrl+C = avsluta" : "upp/ned F5 = uppdatera Esc/q = tillbaka" " Ctrl+C = avsluta (endast ägare kan ändra)"); refresh(); int ch = ui_getch(); if (ch == KEY_UP && sel > 0) sel--; else if (ch == KEY_DOWN && sel < nf - 1) sel++; else if (ch == KEY_F(5)) break; /* reload */ else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { if (!owner) { message("Företagsuppgifter", "Endast ägare kan ändra företagsuppgifterna."); continue; } char label[160]; snprintf(label, sizeof label, "%s: ", COMPANY_FIELDS[sel].label); char *val = prompt(label, vals[sel], 0); if (!val) continue; if (!company_field_valid(&COMPANY_FIELDS[sel], val)) { message("Ogiltigt värde", "%s", COMPANY_FIELDS[sel].label); 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 (COMPANY_FIELDS[sel].numeric) yyjson_mut_obj_add_int(d, o, COMPANY_FIELDS[sel].key, strtoll(val, NULL, 10)); else yyjson_mut_obj_add_strcpy(d, o, COMPANY_FIELDS[sel].key, val); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *r = client_rpc(&a->conn, "org.update", a->session, a->org, args); free(args); if (!r || !client_ok(r)) show_error("Kunde inte spara", r); free(r); break; /* reload from the server */ } else if (ch == 27 || ch == 'q') { back = 1; break; } } for (int i = 0; i < nf; i++) free(vals[i]); free(vals); if (back || g_quit) 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->conn, "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->conn, "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->conn, "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; } } } /* Screen names for Ctrl+R restoration and --screen. Only the top-level views; sub-screens unwind to their parent. */ static void open_scene(struct app *a, const char *scene) { snprintf(g_scene, sizeof g_scene, "%s", scene); if (strcmp(scene, "vouchers") == 0) vouchers_screen(a); else if (strcmp(scene, "inbox") == 0) inbox_screen(a); else if (strcmp(scene, "ib") == 0) ib_screen(a); else if (strcmp(scene, "templates") == 0) templates_screen(a); else if (strcmp(scene, "reports") == 0) reports_screen(a); else if (strcmp(scene, "audit") == 0) audit_screen(a); else if (strcmp(scene, "settings") == 0) settings_screen(a); else if (strcmp(scene, "company") == 0) company_screen(a); } static void do_reload(struct app *a, const char *self) { char orgbuf[32], fybuf[32]; char *argv[12]; int n = 0; snprintf(orgbuf, sizeof orgbuf, "%lld", (long long)a->org); snprintf(fybuf, sizeof fybuf, "%lld", (long long)a->fy); argv[n++] = (char *)(self && *self ? self : "bokftui"); argv[n++] = "--org"; argv[n++] = orgbuf; if (a->fy > 0) { argv[n++] = "--fy"; argv[n++] = fybuf; } argv[n++] = "--screen"; argv[n++] = g_reload_scene[0] ? g_reload_scene : g_scene; argv[n] = NULL; setenv("BOKFD_SESSION", a->session, 1); endwin(); execvp(argv[0], argv); fprintf(stderr, "bokftui: kunde inte ladda om: %s\n", strerror(errno)); } static const char *const MAIN_ITEMS[] = { "Verifikat", "Underlag (inkorg)", "Ingående balans", "Mallar", "Rapporter", "Revision", "Inställningar", "Företagsuppgifter", "Byt räkenskapsår", "Logga ut / avsluta", }; static void dashboard(struct app *a) { for (;;) { int sel = menu("Bokf", MAIN_ITEMS, 10, 1); snprintf(g_scene, sizeof g_scene, "%s", "dashboard"); 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: open_scene(a, "vouchers"); break; case 1: open_scene(a, "inbox"); break; case 2: open_scene(a, "ib"); break; case 3: open_scene(a, "templates"); break; case 4: open_scene(a, "reports"); break; case 5: open_scene(a, "audit"); break; case 6: open_scene(a, "settings"); break; case 7: open_scene(a, "company"); break; case 8: select_fiscal_year(a); break; case 9: return; } } } /* ------------------------------------------------------------------ */ /* login */ /* ------------------------------------------------------------------ */ /* The login screen remembers server and user (never the password) in $XDG_CONFIG_HOME/bokf/tui.conf, defaulting to ~/.config/bokf/tui.conf. */ static void config_path(char *buf, size_t n) { const char *xdg = getenv("XDG_CONFIG_HOME"); if (xdg && *xdg) { snprintf(buf, n, "%s/bokf/tui.conf", xdg); return; } const char *home = getenv("HOME"); snprintf(buf, n, "%s/.config/bokf/tui.conf", home && *home ? home : "."); } static void config_mkdirs(const char *path) { char tmp[512]; snprintf(tmp, sizeof tmp, "%s", path); if (strlen(tmp) >= sizeof tmp) return; for (char *p = tmp + 1; *p; p++) { if (*p != '/') continue; *p = '\0'; mkdir(tmp, 0700); *p = '/'; } } static void config_load(struct app *a) { char path[512]; config_path(path, sizeof path); FILE *f = fopen(path, "r"); if (!f) return; char line[512]; while (fgets(line, sizeof line, f)) { char *nl = strchr(line, '\n'); if (nl) *nl = '\0'; char *eq = strchr(line, '='); if (!eq) continue; *eq = '\0'; const char *val = util_str_trim(eq + 1); if (strcmp(line, "server") == 0 && !a->socket[0]) snprintf(a->socket, sizeof a->socket, "%s", val); else if (strcmp(line, "user") == 0 && !a->username[0]) snprintf(a->username, sizeof a->username, "%s", val); } fclose(f); } static void config_save(const struct app *a) { char path[512]; config_path(path, sizeof path); config_mkdirs(path); FILE *f = fopen(path, "w"); if (!f) return; fprintf(f, "server=%s\nuser=%s\n", a->socket, a->username); fclose(f); chmod(path, 0600); } /* Connects and opens a session. Returns 0 on success, -2 when the connection failed and -1 when the server rejected the login. */ static int try_login(struct app *a, const char *user, const char *pass, const char *token, char **err_out) { *err_out = NULL; if (client_connect(a->socket, &a->conn) != 0) { *err_out = xstrdup(client_last_error()); return -2; } char *session = NULL; int rc; if (token && *token) rc = client_token_login(&a->conn, token, &session, err_out); else rc = client_login(&a->conn, user, pass, &session, err_out); if (rc != 0) { client_close(&a->conn); return -1; } snprintf(a->session, sizeof a->session, "%s", session); if (user && *user) snprintf(a->username, sizeof a->username, "%s", user); free(session); if (token && *token) { char *resp = client_rpc(&a->conn, "session.whoami", a->session, 0, NULL); char *u = jstr_dup(resp, "result.user.username"); if (u && *u) snprintf(a->username, sizeof a->username, "%s", u); free(u); free(resp); } return 0; } 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_pass = getenv("BOKFD_PASSWORD"); snprintf(user, sizeof user, "%s", a->username); 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); char *err = NULL; int rc = try_login(a, user, pass, NULL, &err); if (rc == -2) { message("Fel", "Kunde inte ansluta till %s: %s", a->socket, err ? err : "okänt fel"); free(err); continue; } if (rc != 0) { if (err && 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); } else { message("Inloggning misslyckades", "%s", err ? err : "transportfel"); } free(err); continue; } config_save(a); return 0; } } static int select_org(struct app *a) { char *resp = client_rpc(&a->conn, "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 path, tcp:host:port or tls:host:port\n" " --user NAME prefill username\n" " --org ID select org directly\n" " --fy ID select fiscal year directly\n" " --session ID reuse an open session (or BOKFD_SESSION)\n" " --screen NAME start in a view: dashboard, vouchers, inbox,\n" " ib, templates, reports, audit, settings, company\n" " --version\n"); } int main(int argc, char **argv) { struct app app; memset(&app, 0, sizeof app); app.conn.fd = -1; const char *self = argc > 0 ? argv[0] : "bokftui"; const char *cli_socket = NULL, *cli_user = NULL, *cli_session = NULL; const char *screen_arg = NULL; int64_t want_org = 0, want_fy = 0; for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--socket") == 0 && i + 1 < argc) cli_socket = argv[++i]; else if (strcmp(argv[i], "--user") == 0 && i + 1 < argc) cli_user = argv[++i]; else if (strcmp(argv[i], "--session") == 0 && i + 1 < argc) cli_session = argv[++i]; else if (strcmp(argv[i], "--screen") == 0 && i + 1 < argc) screen_arg = argv[++i]; else if (strcmp(argv[i], "--fy") == 0 && i + 1 < argc) want_fy = strtoll(argv[++i], NULL, 10); 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; } } const char *env_socket = getenv("BOKFD_SOCKET"); const char *env_user = getenv("BOKFD_USER"); if (cli_socket) snprintf(app.socket, sizeof app.socket, "%s", cli_socket); else if (env_socket) snprintf(app.socket, sizeof app.socket, "%s", env_socket); if (cli_user) snprintf(app.username, sizeof app.username, "%s", cli_user); else if (env_user) snprintf(app.username, sizeof app.username, "%s", env_user); config_load(&app); if (!app.socket[0]) snprintf(app.socket, sizeof app.socket, "%s", "/run/bokfd/bokfd.sock"); setlocale(LC_ALL, ""); initscr(); set_escdelay(100); cbreak(); noecho(); keypad(stdscr, TRUE); curs_set(1); const char *env_session = getenv("BOKFD_SESSION"); const char *session_arg = cli_session ? cli_session : env_session; if (session_arg && *session_arg && client_connect(app.socket, &app.conn) == 0) { char *resp = client_rpc(&app.conn, "session.whoami", session_arg, 0, NULL); if (resp && client_ok(resp)) snprintf(app.session, sizeof app.session, "%s", session_arg); else client_close(&app.conn); free(resp); } const char *token = getenv("BOKFD_TOKEN"); if (!app.session[0] && token && *token) { char *err = NULL; if (try_login(&app, NULL, NULL, token, &err) == 0) { config_save(&app); } else { message("Token", "Token-inloggning misslyckades: %s", err ? err : "okänt fel"); free(err); } } if (!app.session[0] && 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); if (want_fy > 0) { char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)want_fy); char *resp = client_rpc(&app.conn, "fiscal_year.get", app.session, app.org, args); if (resp && client_ok(resp)) { app.fy = want_fy; 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(app.fy_label, sizeof app.fy_label, "%s", label); if (start) snprintf(app.fy_start, sizeof app.fy_start, "%s", start); if (end) snprintf(app.fy_end, sizeof app.fy_end, "%s", end); free(label); free(start); free(end); } free(resp); } update_status(&app); if (screen_arg && *screen_arg && strcmp(screen_arg, "dashboard") != 0) open_scene(&app, screen_arg); dashboard(&app); if (g_reload) do_reload(&app, self); client_rpc(&app.conn, "session.close", app.session, 0, "{}"); acct_cache_free(); client_close(&app.conn); endwin(); return 0; }