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