From e352cd34d30acc1955c62027c779ac98004ac201 Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Sat, 19 Sep 2026 22:33:33 +0200 Subject: tui: migrate the remaining screens to the widget layer --- clients/bokftui.c | 2752 ++++++++++++++++++++++------------------------------- clients/tui.c | 212 ++++- clients/tui.h | 31 +- 3 files changed, 1372 insertions(+), 1623 deletions(-) (limited to 'clients') diff --git a/clients/bokftui.c b/clients/bokftui.c index 0967f51..af6e284 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -806,6 +806,160 @@ done: free(resp); } +struct vdctx { + struct app *a; + int64_t id; + size_t natts; + const char *resp; + int corrected; + int want_refresh; + int posted_new; + int64_t posted_id; +}; + +static int vd_key(void *ud, int ch) +{ + struct vdctx *ctx = ud; + struct app *a = ctx->a; + if (ch == TUI_KEY_CTRL_N) { + int64_t nid = vouchers_new(a); + if (nid > 0) { + ctx->posted_id = nid; + ctx->posted_new = 1; + return TUI_HOOK_BACK; + } + return TUI_HOOK_STAY; + } + if (ch == 'c') { + char reasonbuf[512]; + char *reason = + tui_prompt_into(reasonbuf, sizeof reasonbuf, "Beskriv rättelsen: ", + "", 0) + ? reasonbuf + : NULL; + 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", ctx->id); + yyjson_mut_obj_add_strcpy(d, o, "description", reason); + char *ref = util_random_id("tui-", 8); + yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref); + char *cargs = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + char *r = client_rpc(&a->conn, "voucher.correct", a->session, + a->org, cargs); + if (r && client_ok(r)) { + int64_t new_id = jint_val(r, "result.id", 0); + tui_message("Rättat", "Ändringsverifikat %lld skapat", + (long long)new_id); + ctx->corrected = 1; + } else { + show_error("Kunde inte rätta", r); + } + free(r); + free(cargs); + free(ref); + } + return TUI_HOOK_BACK; + } + if (ch == 6) { /* ^F: attach a file to this voucher */ + 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) { + tui_message("Bilaga", "Filen är %.1f MB, max %ld MB.", + (double)sb.st_size / (1024 * 1024), + a->max_attachment_bytes / (1024 * 1024)); + free(path); + } else { + char *b64 = read_file_b64(path); + if (!b64) { + tui_message("Fel", "Kunde inte läsa %s", path); + free(path); + } 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); + yyjson_mut_obj_add_int(d, o, "voucher_id", ctx->id); + char *fargs = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + free(b64); + free(path); + char *r = client_rpc(&a->conn, "attachment.put", a->session, + a->org, fargs); + if (r && client_ok(r)) + tui_message("Underlag", "Bifogat."); + else + show_error("Kunde inte bifoga", r); + free(r); + free(fargs); + ctx->want_refresh = 1; + return TUI_HOOK_BACK; + } + } + } + return TUI_HOOK_STAY; + } + if (ch == 'f' && ctx->natts > 0) { + char **alines = xcalloc(ctx->natts, sizeof(char *)); + int64_t *aids = xcalloc(ctx->natts, sizeof(int64_t)); + for (size_t i = 0; i < ctx->natts; i++) { + char path[64]; + snprintf(path, sizeof path, "result.attachments.%zu.id", i); + aids[i] = jint_val(ctx->resp, path, 0); + snprintf(path, sizeof path, "result.attachments.%zu.filename", i); + char *fn = jstr_dup(ctx->resp, path); + alines[i] = xstrdup(fn ? fn : "(namnlös)"); + free(fn); + } + int acursor = 0; + int asel = tui_select_list("Underlag", alines, (int)ctx->natts, 0, 0, + &acursor, 0, "ta bort", 0); + if (asel >= 0) { + attachment_download(a, aids[asel]); + } else if (asel == -6 && acursor >= 0 && + (size_t)acursor < ctx->natts) { + char q[320]; + snprintf(q, sizeof q, "Ta bort '%s' från verifikatet? (j/n): ", + alines[acursor]); + char ansbuf[512]; + char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, q, "n", 0) + ? ansbuf + : NULL; + if (ans && (ans[0] == 'j' || ans[0] == 'J')) { + char uargs[64]; + snprintf(uargs, sizeof uargs, "{\"id\":%lld,\"voucher_id\":%lld}", + (long long)aids[acursor], (long long)ctx->id); + char *r = client_rpc(&a->conn, "attachment.unlink", a->session, + a->org, uargs); + if (r && client_ok(r)) { + tui_message("Underlag", "Länken borttagen."); + ctx->want_refresh = 1; + for (size_t i = 0; i < ctx->natts; i++) + free(alines[i]); + free(alines); + free(aids); + return TUI_HOOK_REFRESH; + } + show_error("Kunde inte ta bort", r); + free(r); + } + } + for (size_t i = 0; i < ctx->natts; i++) + free(alines[i]); + free(alines); + free(aids); + return TUI_HOOK_STAY; + } + return TUI_HOOK_NONE; +} + static int voucher_detail(struct app *a, int64_t id, int64_t *out_new) { if (out_new) @@ -882,181 +1036,26 @@ static int voucher_detail(struct app *a, int64_t id, int64_t *out_new) } 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) { - char hint[256]; - snprintf(hint, sizeof hint, - "upp/ned F5 = uppdatera ^N = nytt verifikat c =" - " rätta ^F = bifoga%s Esc/q = tillbaka", - natts ? " f = underlag" : ""); - tui_frame("Verifikat"); - for (int i = 0; i < view && top + i < n; i++) - mvaddnstr(2 + i, 2, lines[top + i], COLS - 4); - tui_hints(hint); - 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 reasonbuf[512]; char *reason = tui_prompt_into(reasonbuf, sizeof reasonbuf, "Beskriv rättelsen: ", "", 0) ? reasonbuf : NULL; - if (reason && *reason) { - yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); - yyjson_mut_val *o = yyjson_mut_obj(d); - yyjson_mut_doc_set_root(d, o); - yyjson_mut_obj_add_int(d, o, "voucher", id); - yyjson_mut_obj_add_strcpy(d, o, "description", reason); - char *ref = util_random_id("tui-", 8); - yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref); - char *cargs = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - char *r = client_rpc(&a->conn, "voucher.correct", a->session, - a->org, cargs); - if (r && client_ok(r)) { - int64_t new_id = jint_val(r, "result.id", 0); - tui_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 == 6) { /* ^F: attach a file to this voucher */ - 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) { - tui_message("Bilaga", "Filen är %.1f MB, max %ld MB.", - (double)sb.st_size / (1024 * 1024), - a->max_attachment_bytes / (1024 * 1024)); - free(path); - } else { - char *b64 = read_file_b64(path); - if (!b64) { - tui_message("Fel", "Kunde inte läsa %s", path); - free(path); - } 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); - yyjson_mut_obj_add_int(d, o, "voucher_id", id); - char *fargs = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - free(b64); - free(path); - char *r = client_rpc(&a->conn, "attachment.put", - a->session, a->org, fargs); - if (r && client_ok(r)) - tui_message("Underlag", "Bifogat."); - else - show_error("Kunde inte bifoga", r); - free(r); - free(fargs); - want_refresh = 1; - stop = 1; - } - } - } - } else if (ch == 'f' && natts > 0) { - char **alines = xcalloc(natts, sizeof(char *)); - int64_t *aids = xcalloc(natts, sizeof(int64_t)); - for (size_t i = 0; i < natts; i++) { - char path[64]; - snprintf(path, sizeof path, "result.attachments.%zu.id", - i); - aids[i] = jint_val(resp, path, 0); - snprintf(path, sizeof path, - "result.attachments.%zu.filename", i); - char *fn = jstr_dup(resp, path); - alines[i] = xstrdup(fn ? fn : "(namnlös)"); - free(fn); - } - int acursor = 0; - int asel = tui_select_list("Underlag", alines, (int)natts, 0, 0, - &acursor, 0, "ta bort", 0); - if (asel >= 0) - attachment_download(a, aids[asel]); - else if (asel == -6 && acursor >= 0 && - (size_t)acursor < natts) { - char q[320]; - snprintf(q, sizeof q, - "Ta bort '%s' från verifikatet? (j/n): ", - alines[acursor]); - char ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, q, "n", 0) ? ansbuf : NULL; - if (ans && (ans[0] == 'j' || ans[0] == 'J')) { - char uargs[64]; - snprintf(uargs, sizeof uargs, - "{\"id\":%lld,\"voucher_id\":%lld}", - (long long)aids[acursor], (long long)id); - char *r = client_rpc(&a->conn, "attachment.unlink", - a->session, a->org, uargs); - if (r && client_ok(r)) { - tui_message("Underlag", "Länken borttagen."); - want_refresh = 1; - stop = 1; - } else { - show_error("Kunde inte ta bort", r); - } - free(r); - } - } - for (size_t i = 0; i < natts; i++) - free(alines[i]); - free(alines); - free(aids); - } 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); + struct vdctx ctx; + memset(&ctx, 0, sizeof ctx); + ctx.a = a; + ctx.id = id; + ctx.natts = natts; + ctx.resp = resp; + char hint[256]; + snprintf(hint, sizeof hint, + "^N = nytt verifikat c = rätta ^F = bifoga%s", + natts ? " f = underlag" : ""); + int want_refresh = 0, corrected = 0; + int ret = tui_pager_hook("Verifikat", (const char *)text.p, hint, 0, + vd_key, &ctx); + if (ret == 1) + want_refresh = 1; + if (ctx.want_refresh) + want_refresh = 1; + corrected = ctx.corrected; + int posted_new = ctx.posted_new; + int64_t posted_id = ctx.posted_id; free(series); free(date); free(desc); @@ -1081,43 +1080,6 @@ struct tui_vrow { 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; @@ -1190,546 +1152,470 @@ static int acct_exists(struct app *a, const char *number) 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]; + +struct vform { + struct app *a; + struct tui_rt rt; + struct tui_vrow rows[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; + int natt; + char date[16]; + char series[16]; + char desc[256]; + char client_ref[64]; +}; - for (;;) { - tui_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]); - tui_pad_field(padded, sizeof padded, w); - if (field == k) { - attron(A_REVERSE); - caret_y = hy[k]; - size_t slen = strlen(hvals[k]); - size_t cp = (field_fresh || field_pos == (size_t)-1 || - field_pos > slen) - ? slen - : field_pos; - int cw = (int)tui_disp_width_n(hvals[k], cp); - 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 (tui_parse_kr(rows[i].debit, &v) == 0) - sum_d += v; - if (tui_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]); - tui_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)tui_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 : ""); - tui_pad_field(nbuf, sizeof nbuf, name_w); - attron(A_DIM); - mvaddstr(y, x_name, nbuf); - attroff(A_DIM); - } +static void vform_cell(void *ud, int row, int col, char **buf, size_t *cap) +{ + struct vform *vf = ud; + struct tui_vrow *r = &vf->rows[row]; + if (col == 0) { + *buf = r->account; + *cap = sizeof r->account; + } else if (col == 2) { + *buf = r->debit; + *cap = sizeof r->debit; + } else if (col == 3) { + *buf = r->credit; + *cap = sizeof r->credit; + } else { + *buf = r->text; + *cap = sizeof r->text; + } +} + +static void vform_info(void *ud, int row, char *buf, size_t n) +{ + struct vform *vf = ud; + const char *nm = acct_name(vf->a, vf->rows[row].account); + snprintf(buf, n, "%s", nm ? nm : ""); +} + +static void vform_footer(void *ud, char *buf, size_t n) +{ + struct vform *vf = ud; + int64_t sum_d = 0, sum_c = 0; + for (int i = 0; i < vf->rt.nrows; i++) { + int64_t v; + if (tui_parse_kr(vf->rows[i].debit, &v) == 0) + sum_d += v; + if (tui_parse_kr(vf->rows[i].credit, &v) == 0) + sum_c += v; + } + char d1[32], c1[32], diff[32], line[1024]; + tui_kr_format(sum_d, d1, sizeof d1); + tui_kr_format(sum_c, c1, sizeof c1); + tui_kr_format(sum_d - sum_c, diff, sizeof diff); + int balanced = sum_d == sum_c; + struct buf t; + buf_init(&t); + if (vf->natt > 0) { + snprintf(line, sizeof line, "Bilagor: "); + for (int i = 0; i < vf->natt; i++) { + size_t used = strlen(line); + if (used + 1 >= sizeof line) + break; + snprintf(line + used, sizeof line - used, "%.79s%s", + vf->att_names[i], i + 1 < vf->natt ? ", " : ""); } - char d1[32], c1[32], diff[32]; - tui_kr_format(sum_d, d1, sizeof d1); - tui_kr_format(sum_c, c1, sizeof c1); - tui_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 ? ", " : ""); + buf_append(&t, line, strlen(line)); + buf_append(&t, "\n", 1); + } + snprintf(line, sizeof line, "Summa debet %12s kredit %12s %s", d1, c1, + balanced ? "I BALANS" : "DIFF"); + buf_append(&t, line, strlen(line)); + if (!balanced) { + snprintf(line, sizeof line, "\nDifferens: %s", diff); + buf_append(&t, line, strlen(line)); + } + buf_append(&t, "\0", 1); + snprintf(buf, n, "%s", (char *)t.p); + buf_free(&t); +} + +static void vform_attach(struct vform *vf) +{ + struct app *a = vf->a; + char *path = file_browser(a, a->attachment_dir); + if (!path) + return; + struct stat sb; + if (stat(path, &sb) == 0 && + (long)sb.st_size > a->max_attachment_bytes) { + tui_message("Bilaga", "Filen är %.1f MB, max %ld MB.", + (double)sb.st_size / (1024 * 1024), + a->max_attachment_bytes / (1024 * 1024)); + free(path); + return; + } + char *b64 = read_file_b64(path); + if (!b64) { + tui_message("Bilaga", "Kunde inte läsa %s", path); + free(path); + return; + } + if (vf->natt >= 32) { + tui_message("Bilaga", "Max 32 bilagor per verifikat."); + free(b64); + free(path); + return; + } + const char *base = strrchr(path, '/'); + base = base ? base + 1 : path; + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_strcpy(d, o, "filename", base); + yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + size_t need = strlen(args) + 256; + char *raw = xmalloc(need); + snprintf(raw, need, + "{\"v\":1,\"id\":\"tui\",\"cmd\":\"attachment.put\"," + "\"session\":\"%s\",\"org\":%lld,\"args\":%s}", + a->session, (long long)a->org, args); + free(args); + char *r = NULL; + if (client_send_line(&a->conn, raw) == 0) + r = client_read_line(&a->conn); + free(raw); + if (r && client_ok(r)) { + vf->att_ids[vf->natt] = jint_val(r, "result.id", 0); + snprintf(vf->att_names[vf->natt], sizeof vf->att_names[vf->natt], + "%s", base); + vf->natt++; + } else { + show_error("Kunde inte bifoga filen", r); + } + free(r); + free(b64); + free(path); +} + +static void vform_template(struct vform *vf) +{ + struct app *a = vf->a; + char *lresp = client_rpc(&a->conn, "template.list", a->session, a->org, + "{\"active_only\":true}"); + if (!lresp || !client_ok(lresp)) { + show_error("Mallar", lresp); + free(lresp); + return; + } + size_t ln = jarr_size(lresp, "result.items"); + if (ln == 0) { + tui_message("Mallar", + "Inga mallar ännu. Skapa en under Mallar först."); + free(lresp); + return; + } + 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); + char nmbuf[128]; + snprintf(nmbuf, sizeof nmbuf, "%s", nm ? nm : ""); + tui_pad_field(nmbuf, sizeof nmbuf, 24); + snprintf(line, sizeof line, "%s %-3s %2lld rader %s", nmbuf, + ser ? ser : "", (long long)rc, ds ? ds : ""); + names[i] = xstrdup(nm ? nm : ""); + lines[i] = xstrdup(line); + free(nm); + free(ser); + free(ds); + } + int tsel = tui_select_list("Välj mall", lines, (int)ln, 0, 1, NULL, 0, + NULL, 0); + char tname[128] = ""; + 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) + return; + 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", tname); + char *targs = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + char *resp = + client_rpc(&a->conn, "template.get", a->session, a->org, targs); + free(targs); + if (!resp || !client_ok(resp)) { + show_error("Mall", resp); + free(resp); + return; + } + char xsbuf[512]; + char *xs = tui_prompt_into(xsbuf, sizeof xsbuf, "Belopp (x, kr): ", "", + 0) + ? xsbuf + : NULL; + double xv = 0; + if (xs && *xs && !parse_x_double(xs, &xv)) { + tui_message("Fel", "Ogiltigt belopp."); + free(resp); + return; + } + 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) { + tui_message("Mall", "%s", + ferr[0] ? ferr : "kunde inte lösa mallen"); + } else { + memset(vf->rows, 0, sizeof vf->rows); + int use = (int)(on > 64 ? 64 : on); + for (int i = 0; i < use; i++) { + snprintf(vf->rows[i].account, sizeof vf->rows[i].account, "%s", + out[i].account); + char tmp[32]; + if (out[i].debit_ore) { + tui_kr_format(out[i].debit_ore, tmp, sizeof tmp); + snprintf(vf->rows[i].debit, sizeof vf->rows[i].debit, "%s", + tmp); + } else { + tui_kr_format(out[i].credit_ore, tmp, sizeof tmp); + snprintf(vf->rows[i].credit, sizeof vf->rows[i].credit, "%s", + tmp); } - 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); - tui_hints("F4 = mall ^F = bifoga fil Tab = byta fält F5 = validera" - " ^X = rensa rad ^Enter = 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; + if (out[i].description[0]) + snprintf(vf->rows[i].text, sizeof vf->rows[i].text, "%s", + out[i].description); + } + vf->rt.nrows = use > 0 ? use : 1; + char *tds = jstr_dup(resp, "result.description"); + char *tser = jstr_dup(resp, "result.series"); + if (!vf->desc[0] && tds && *tds) + replace_x_into(tds, xv, vf->desc, sizeof vf->desc); + if (tser && *tser) + snprintf(vf->series, sizeof vf->series, "%s", tser); + free(tds); + free(tser); + vf->rt.row = 0; + vf->rt.col = 0; + vf->rt.fresh = 1; + vf->rt.pos = (size_t)-1; + tui_rt_normalize(&vf->rt); + } + 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); +} + +/* Returns the posted voucher id, or 0 to stay in the form. */ +static int64_t vform_post(struct vform *vf, int dry) +{ + struct app *a = vf->a; + 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", vf->date); + yyjson_mut_obj_add_strcpy(d, o, "description", vf->desc); + yyjson_mut_obj_add_strcpy(d, o, "series", vf->series); + yyjson_mut_obj_add_strcpy(d, o, "client_ref", vf->client_ref); + yyjson_mut_val *arr = yyjson_mut_arr(d); + for (int i = 0; i < vf->rt.nrows; i++) { + if (!vf->rows[i].account[0]) continue; + int64_t dv = 0, cv = 0; + if (tui_parse_kr(vf->rows[i].debit, &dv) != 0 || + tui_parse_kr(vf->rows[i].credit, &cv) != 0) { + tui_message("Fel", "Ogiltigt belopp på rad %d", i + 1); + yyjson_mut_doc_free(d); + return 0; } - 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; - } + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr); + yyjson_mut_obj_add_strcpy(d, ro, "account", vf->rows[i].account); + yyjson_mut_obj_add_int(d, ro, "debit_ore", dv); + yyjson_mut_obj_add_int(d, ro, "credit_ore", cv); + if (vf->rows[i].text[0]) + yyjson_mut_obj_add_strcpy(d, ro, "description", + vf->rows[i].text); + } + yyjson_mut_obj_add_val(d, o, "rows", arr); + if (vf->natt > 0) { + yyjson_mut_val *aa = yyjson_mut_arr(d); + for (int i = 0; i < vf->natt; i++) + yyjson_mut_arr_add_int(d, aa, vf->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 (dry) { + char *raw = xmalloc(strlen(args) + 256); + sprintf(raw, + "{\"v\":1,\"id\":\"tui\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%lld,\"dry_run\":true," + "\"args\":%s}", + a->session, (long long)a->org, args); + if (client_send_line(&a->conn, raw) == 0) { + char *r = client_read_line(&a->conn); + if (r && client_ok(r)) { + int64_t num = jint_val(r, "result.number", 0); + tui_message("Validering OK", + "Nummer %lld skulle tilldelas.", (long long)num); + } else { + show_error("Validering misslyckades", r); } - continue; + free(r); + } else { + tui_message("Fel", "Kunde inte nå servern"); } - 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; + free(raw); + } else { + char *r = client_rpc(&a->conn, "voucher.post", a->session, a->org, + args); + if (r && client_ok(r)) { + int64_t id = jint_val(r, "result.id", 0); + int64_t num = jint_val(r, "result.number", 0); + char *ser = jstr_dup(r, "result.series"); + int replayed = jbool_val(r, "result.replayed", 0); + tui_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); + return id; } - if (ch == KEY_F(4)) { - char tname[128] = ""; - char *lresp = client_rpc(&a->conn, "template.list", a->session, - a->org, "{\"active_only\":true}"); - if (!lresp || !client_ok(lresp)) { - show_error("Mallar", lresp); - free(lresp); + show_error("Kunde inte bokföra", r); + free(r); + } + free(args); + return 0; +} + +static int vform_key(void *ud, int ch) +{ + struct vform *vf = ud; + if (ch == KEY_F(4)) { + vform_template(vf); + return TUI_HOOK_STAY; + } + if (ch == 6) { + vform_attach(vf); + return TUI_HOOK_STAY; + } + return TUI_HOOK_NONE; +} + +static int64_t vouchers_new(struct app *a) +{ + struct vform vf; + memset(&vf, 0, sizeof vf); + vf.a = a; + snprintf(vf.date, sizeof vf.date, "%s", + a->fy_start[0] ? a->fy_start : "2026-01-01"); + snprintf(vf.series, sizeof vf.series, "%s", a->default_series); + char *ref = util_random_id("tui-", 8); + snprintf(vf.client_ref, sizeof vf.client_ref, "%s", ref); + free(ref); + int text_w = COLS - 60; + if (text_w < 8) + text_w = 8; + struct tui_rt_col cols[5] = { + { "Konto", 2, 6, TUI_RT_EDIT }, + { "Namn", 9, 24, TUI_RT_INFO }, + { "Debet", 34, 11, TUI_RT_EDIT }, + { "Kredit", 46, 11, TUI_RT_EDIT }, + { "Text", 58, text_w, TUI_RT_EDIT }, + }; + tui_rt_init(&vf.rt, 1, 64, 5, cols, 7, vform_cell, vform_info, &vf); + tui_rt_set_footer(&vf.rt, vform_footer); + tui_rt_set_key(&vf.rt, vform_key); + struct tui_form_field ff[3]; + memset(ff, 0, sizeof ff); + ff[0].label = "Datum"; + ff[0].value = vf.date; + ff[0].cap = sizeof vf.date; + ff[0].kind = TUI_F_DATE; + ff[1].label = "Serie"; + ff[1].value = vf.series; + ff[1].cap = sizeof vf.series; + ff[1].kind = TUI_F_TEXT; + ff[2].label = "Text"; + ff[2].value = vf.desc; + ff[2].cap = sizeof vf.desc; + ff[2].kind = TUI_F_TEXT; + const char *hint = "F4 = mall ^F = bifoga fil Tab = byta fält F5 =" + " validera ^X = rensa rad ^Enter = bokför Esc =" + " avbryt"; + int stage = 0; + for (;;) { + if (stage == 0) { + int r = tui_form_run_hook("Nytt verifikat", ff, 3, 1, vform_key, + &vf); + if (r == TUI_FORM_BACK) + return 0; + if (r == TUI_FORM_REFRESH) { + vform_post(&vf, 1); continue; } - size_t ln = jarr_size(lresp, "result.items"); - if (ln == 0) { - tui_message("Mallar", - "Inga mallar ännu. Skapa en under Mallar först."); - free(lresp); + if (r == TUI_FORM_SUBMIT) { + int64_t id = vform_post(&vf, 0); + if (id > 0) + return id; 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); - char nmbuf[128]; - snprintf(nmbuf, sizeof nmbuf, "%s", nm ? nm : ""); - tui_pad_field(nmbuf, sizeof nmbuf, 24); - snprintf(line, sizeof line, "%s %-3s %2lld rader %s", nmbuf, - ser ? ser : "", (long long)rc, ds ? ds : ""); - names[i] = xstrdup(nm ? nm : ""); - lines[i] = xstrdup(line); - free(nm); - free(ser); - free(ds); - } - int tsel = tui_select_list("Välj mall", lines, (int)ln, 0, 1, NULL, 0, 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) + stage = 1; + } else { + int rr = tui_rt_run("Nytt verifikat", &vf.rt, hint); + if (rr == -1) { + stage = 0; continue; - { - char tbuf[128]; - snprintf(tbuf, sizeof tbuf, "%s", tname); - yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); - yyjson_mut_val *to = yyjson_mut_obj(d); - yyjson_mut_doc_set_root(d, to); - yyjson_mut_obj_add_strcpy(d, to, "name", tbuf); - char *targs = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - char *resp = client_rpc(&a->conn, "template.get", a->session, - a->org, targs); - free(targs); - if (!resp || !client_ok(resp)) { - show_error("Mall", resp); - free(resp); - } else { - char xsbuf[512]; char *xs = tui_prompt_into(xsbuf, sizeof xsbuf, "Belopp (x, kr): ", "", 0) ? xsbuf : NULL; - double xv = 0; - if (xs && *xs && !parse_x_double(xs, &xv)) { - tui_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) { - tui_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) { - tui_kr_format(out[i].debit_ore, tmp, sizeof tmp); - snprintf(rows[i].debit, sizeof rows[i].debit, - "%s", tmp); - } else { - tui_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) { /* ^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) { - tui_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) { - tui_message("Bilaga", "Kunde inte läsa %s", path); - } else if (natt >= 32) { - tui_message("Bilaga", "Max 32 bilagor per verifikat."); - } else { - const char *base = strrchr(path, '/'); - base = base ? base + 1 : path; - yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); - yyjson_mut_val *o = yyjson_mut_obj(d); - yyjson_mut_doc_set_root(d, o); - yyjson_mut_obj_add_strcpy(d, o, "filename", base); - yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64); - char *args = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - size_t need = strlen(args) + 256; - char *raw = xmalloc(need); - snprintf(raw, need, - "{\"v\":1,\"id\":\"tui\",\"cmd\":" - "\"attachment.put\",\"session\":\"%s\"," - "\"org\":%lld,\"args\":%s}", - a->session, (long long)a->org, args); - free(args); - char *r = NULL; - if (client_send_line(&a->conn, raw) == 0) - r = client_read_line(&a->conn); - free(raw); - if (r && client_ok(r)) { - att_ids[natt] = jint_val(r, "result.id", 0); - snprintf(att_names[natt], sizeof att_names[natt], "%s", - base); - natt++; - } else { - show_error("Kunde inte bifoga filen", r); - } - free(r); - } - free(b64); - free(path); - } - continue; - } - if (ch == KEY_CTRL_X) { - 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_CTRL_ENTER || 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 (tui_parse_kr(rows[i].debit, &dv) != 0 || - tui_parse_kr(rows[i].credit, &cv) != 0) { - tui_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); + if (rr == -2) { + vform_post(&vf, 1); + continue; } - char *args = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - if (do_check) { - char *raw = xmalloc(strlen(args) + 256); - sprintf(raw, - "{\"v\":1,\"id\":\"tui\",\"cmd\":\"voucher.post\"," - "\"session\":\"%s\",\"org\":%lld,\"dry_run\":true," - "\"args\":%s}", - a->session, (long long)a->org, args); - if (client_send_line(&a->conn, raw) == 0) { - char *r = client_read_line(&a->conn); - if (r && client_ok(r)) { - int64_t num = jint_val(r, "result.number", 0); - tui_message("Validering OK", - "Nummer %lld skulle tilldelas.", (long long)num); - } else { - show_error("Validering misslyckades", r); - } - free(r); - } else { - tui_message("Fel", "Kunde inte nå servern"); - } - free(raw); - } else { - char *r = client_rpc(&a->conn, "voucher.post", a->session, a->org, - args); - if (r && client_ok(r)) { - int64_t id = jint_val(r, "result.id", 0); - int64_t num = jint_val(r, "result.number", 0); - char *ser = jstr_dup(r, "result.series"); - int replayed = jbool_val(r, "result.replayed", 0); - tui_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); + if (rr == -3) { + int64_t id = vform_post(&vf, 0); + if (id > 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) - tui_date_field_edit(buf, cap, &field_pos, ch, &field_fresh); - else - tui_field_edit(buf, cap, &field_pos, ch, &field_fresh); - normalize_rows(rows, &nrows, &field); - nfields = 3 + 4 * nrows; } } @@ -3296,9 +3182,94 @@ static int64_t pick_voucher(struct app *a) return out; } +static void inbox_upload(struct app *a) +{ + char *path = file_browser(a, a->attachment_dir); + if (!path) + return; + struct stat sb; + if (stat(path, &sb) == 0 && (long)sb.st_size > a->max_attachment_bytes) { + tui_message("Bilaga", "Filen är %.1f MB, max %ld MB.", + (double)sb.st_size / (1024 * 1024), + a->max_attachment_bytes / (1024 * 1024)); + free(path); + return; + } + char *b64 = read_file_b64(path); + if (!b64) { + tui_message("Fel", "Kunde inte läsa %s", path); + free(path); + return; + } + const char *base = strrchr(path, '/'); + base = base ? base + 1 : path; + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_strcpy(d, o, "filename", base); + yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + free(b64); + size_t reqlen = strlen(args) + 256; + char *raw = xmalloc(reqlen); + snprintf(raw, reqlen, + "{\"v\":1,\"id\":\"tui\",\"cmd\":\"attachment.put\"," + "\"session\":\"%s\",\"org\":%lld,\"args\":%s}", + a->session, (long long)a->org, args); + free(args); + char *r = NULL; + if (client_send_line(&a->conn, raw) == 0) + r = client_read_line(&a->conn); + free(raw); + if (r && client_ok(r)) + tui_message("Underlag", "Sparat."); + else + show_error("Kunde inte spara", r); + free(r); + free(path); +} + +struct inboxctx { + struct app *a; + int64_t *ids; + size_t n; + int cursor; +}; + +static int inbox_key(void *ud, int ch) +{ + struct inboxctx *ctx = ud; + struct app *a = ctx->a; + int64_t sel_id = (ctx->cursor >= 0 && (size_t)ctx->cursor < ctx->n) + ? ctx->ids[ctx->cursor] + : 0; + if (ch == 'k' && sel_id) { + int64_t vid = pick_voucher(a); + if (vid) { + char kargs[64]; + snprintf(kargs, sizeof kargs, "{\"id\":%lld,\"voucher_id\":%lld}", + (long long)sel_id, (long long)vid); + char *r = client_rpc(&a->conn, "attachment.link", a->session, + a->org, kargs); + if (r && client_ok(r)) + tui_message("Underlag", "Kopplat till verifikatet."); + else + show_error("Kunde inte koppla", r); + free(r); + } + return TUI_HOOK_REFRESH; + } + if (ch == 'a' || ch == 'A') { + inbox_upload(a); + return TUI_HOOK_REFRESH; + } + return TUI_HOOK_NONE; +} + static void inbox_screen(struct app *a) { - int top = 0, sel = 0; + int cursor = 0; for (;;) { if (g_quit) return; @@ -3328,118 +3299,27 @@ static void inbox_screen(struct app *a) items[i] = xstrdup(line); free(fn); } - int view = LINES - 4; - if (n == 0) - sel = 0; - else if (sel >= (int)n) - sel = (int)n - 1; - if (sel < top) - top = sel; - if (sel >= top + view) - top = sel - view + 1; - if (top > (int)n - view) - top = (int)n - view; - if (top < 0) - top = 0; - tui_frame("Underlag (inkorg)"); - for (size_t i = (size_t)top; i < n && (int)(i - (size_t)top) < view; - i++) { - int y = 2 + (int)(i - (size_t)top); - if ((int)i == sel) - attron(A_REVERSE); - mvaddnstr(y, 2, items[i], COLS - 4); - if ((int)i == sel) - attroff(A_REVERSE); - } - if (n == 0) - mvaddstr(2, 2, "Inga obokförda underlag."); - tui_hints("Enter = hämta k = koppla till verifikat a/^N = lägg till" - " fil PgUp/PgDn, Home/End rullar F5 = uppdatera Esc/q =" - " tillbaka"); - refresh(); - int ch = ui_getch(); - int64_t sel_id = (sel >= 0 && (size_t)sel < n) ? ids[sel] : 0; + struct inboxctx ctx = { a, ids, n, cursor }; + int sel = tui_select_list_hook("Underlag (inkorg)", items, (int)n, + cursor, 1, &cursor, 1, NULL, 0, + inbox_key, &ctx); + int64_t sel_id = + (sel >= 0 && (size_t)sel < n) ? ids[sel] : 0; for (size_t i = 0; i < n; i++) free(items[i]); free(items); free(ids); free(resp); - if (ch == 27 || ch == 'q') + if (sel == -1) return; - if ((ch == '\n' || ch == '\r' || ch == KEY_ENTER) && sel_id) - attachment_download(a, sel_id); - else if (ch == KEY_DOWN && sel + 1 < (int)n) - sel++; - else if (ch == KEY_UP && sel > 0) - sel--; - else if (ch == KEY_NPAGE) - sel = sel + view < (int)n ? sel + view : (int)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 > 0 ? (int)n - 1 : 0; - else if (ch == 'k' && sel_id) { - int64_t vid = pick_voucher(a); - if (vid) { - char kargs[64]; - snprintf(kargs, sizeof kargs, - "{\"id\":%lld,\"voucher_id\":%lld}", - (long long)sel_id, (long long)vid); - char *r = client_rpc(&a->conn, "attachment.link", a->session, - a->org, kargs); - if (r && client_ok(r)) - tui_message("Underlag", "Kopplat till verifikatet."); - else - show_error("Kunde inte koppla", r); - free(r); - } - } 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) { - tui_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) { - tui_message("Fel", "Kunde inte läsa %s", path); - continue; - } - const char *base = strrchr(path, '/'); - base = base ? base + 1 : path; - yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); - yyjson_mut_val *o = yyjson_mut_obj(d); - yyjson_mut_doc_set_root(d, o); - yyjson_mut_obj_add_strcpy(d, o, "filename", base); - yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64); - char *args = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - free(b64); - size_t reqlen = strlen(args) + 256; - char *raw = xmalloc(reqlen); - snprintf(raw, reqlen, - "{\"v\":1,\"id\":\"tui\",\"cmd\":\"attachment.put\"," - "\"session\":\"%s\",\"org\":%lld,\"args\":%s}", - a->session, (long long)a->org, args); - free(args); - char *r = NULL; - if (client_send_line(&a->conn, raw) == 0) - r = client_read_line(&a->conn); - free(raw); - if (r && client_ok(r)) - tui_message("Underlag", "Sparat."); - else - show_error("Kunde inte spara", r); - free(r); + if (sel == -2) + continue; + if (sel == -4) { + inbox_upload(a); + continue; } + if (sel_id) + attachment_download(a, sel_id); } } @@ -3517,53 +3397,132 @@ struct tui_trow { char text[64]; }; -static int trow_has_content(const struct tui_trow *r) +struct tplform { + struct app *a; + struct tui_rt rt; + struct tui_trow rows[64]; + int nrows; + char name[128]; + char desc[256]; + char series[16]; + int64_t id; +}; + +static void tpl_cell(void *ud, int row, int col, char **buf, size_t *cap) +{ + struct tplform *tf = ud; + struct tui_trow *r = &tf->rows[row]; + if (col == 0) { + *buf = r->account; + *cap = sizeof r->account; + } else if (col == 2) { + *buf = r->formula; + *cap = sizeof r->formula; + } else { + *buf = r->text; + *cap = sizeof r->text; + } +} + +static void tpl_info(void *ud, int row, char *buf, size_t n) +{ + struct tplform *tf = ud; + const char *nm = acct_name(tf->a, tf->rows[row].account); + snprintf(buf, n, "%s", nm ? nm : ""); +} + +static void tpl_footer(void *ud, char *buf, size_t n) { - return r->account[0] || r->formula[0] || r->text[0]; + (void)ud; + snprintf(buf, n, "x = belopp i kronor. Positivt blir debet, negativt" + " kredit."); } -static void trow_normalize(struct tui_trow *rows, int *nrows, int *field) +/* F5 dry-runs; Ctrl+Enter creates/updates. Returns 1 when saved. */ +static int tpl_save(struct tplform *tf, int dry) { - 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)--; + const char *problem = NULL; + char msg[256] = ""; + if (!tf->name[0]) + problem = "Namn saknas."; + int used = 0; + for (int i = 0; i < tf->rt.nrows && !problem; i++) { + if (!tf->rows[i].account[0]) continue; + if (!acct_exists(tf->a, tf->rows[i].account)) { + snprintf(msg, sizeof msg, "Konto %.15s finns inte (rad %d).", + tf->rows[i].account, i + 1); + problem = msg; + break; + } + if (!tf->rows[i].formula[0] || !formula_valid(tf->rows[i].formula)) { + snprintf(msg, sizeof msg, + "Ogiltig formel på rad %d: '%.60s'.", i + 1, + tf->rows[i].formula); + problem = msg; + break; } - i++; + used++; + } + if (!problem && used == 0) + problem = "Mallen behöver minst en rad."; + if (problem) { + tui_message("Mall", "%s", problem); + return 0; } - if (*nrows < 1) { - *nrows = 1; - memset(&rows[0], 0, sizeof rows[0]); - if (*field >= 3) - *field = 3; + + 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 (tf->id) + yyjson_mut_obj_add_int(d, o, "id", tf->id); + yyjson_mut_obj_add_strcpy(d, o, "name", tf->name); + yyjson_mut_obj_add_strcpy(d, o, "series", tf->series); + yyjson_mut_obj_add_strcpy(d, o, "description", tf->desc); + yyjson_mut_val *arr = yyjson_mut_arr(d); + for (int i = 0; i < tf->rt.nrows; i++) { + if (!tf->rows[i].account[0]) + continue; + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr); + yyjson_mut_obj_add_strcpy(d, ro, "account", tf->rows[i].account); + yyjson_mut_obj_add_strcpy(d, ro, "formula", tf->rows[i].formula); + if (tf->rows[i].text[0]) + yyjson_mut_obj_add_strcpy(d, ro, "description", + tf->rows[i].text); + } + yyjson_mut_obj_add_val(d, o, "rows", arr); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + const char *cmd = tf->id ? "template.update" : "template.create"; + char *resp = dry ? rpc_dry(tf->a, cmd, args) + : client_rpc(&tf->a->conn, cmd, tf->a->session, + tf->a->org, args); + free(args); + if (resp && client_ok(resp)) { + if (dry) { + tui_message("Validering OK", "Mallen är korrekt (%d rader).", + used); + } else { + tui_message("Mall", "Mallen '%s' sparad.", tf->name); + free(resp); + return 1; + } + } else { + show_error("Kunde inte spara mallen", resp); } + free(resp); + return 0; } -/* Template editor: a form, not a wizard. name == NULL creates a new +/* Template editor: a form, not a wizard. load_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; - + struct tplform tf; + memset(&tf, 0, sizeof tf); + tf.a = a; + snprintf(tf.series, sizeof tf.series, "%s", a->default_series); if (load_name && *load_name) { yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); @@ -3577,19 +3536,18 @@ static int template_form(struct app *a, const char *load_name) if (!resp || !client_ok(resp)) { show_error("Mall", resp); free(resp); - curs_set(0); return 0; } - tpl_id = jint_val(resp, "result.id", 0); + tf.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); + snprintf(tf.name, sizeof tf.name, "%s", nm); if (ser && *ser) - snprintf(series, sizeof series, "%s", ser); + snprintf(tf.series, sizeof tf.series, "%s", ser); if (ds) - snprintf(tdesc, sizeof tdesc, "%s", ds); + snprintf(tf.desc, sizeof tf.desc, "%s", ds); free(nm); free(ser); free(ds); @@ -3604,292 +3562,77 @@ static int template_form(struct app *a, const char *load_name) 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", + snprintf(tf.rows[i].account, sizeof tf.rows[i].account, "%s", acc ? acc : ""); - snprintf(rows[i].formula, sizeof rows[i].formula, "%s", + snprintf(tf.rows[i].formula, sizeof tf.rows[i].formula, "%s", form ? form : ""); - snprintf(rows[i].text, sizeof rows[i].text, "%s", rd ? rd : ""); + snprintf(tf.rows[i].text, sizeof tf.rows[i].text, "%s", + rd ? rd : ""); free(acc); free(form); free(rd); } - nrows = n > 0 ? (int)n : 1; + tf.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; + int text_w = COLS - 57; if (text_w < 8) text_w = 8; - + struct tui_rt_col cols[4] = { + { "Konto", 2, 6, TUI_RT_EDIT }, + { "Namn", 9, 24, TUI_RT_INFO }, + { "Formel", 34, 20, TUI_RT_EDIT }, + { "Radtext", 55, text_w, TUI_RT_EDIT }, + }; + tui_rt_init(&tf.rt, tf.nrows > 0 ? tf.nrows : 1, 64, 4, cols, 7, tpl_cell, + tpl_info, &tf); + tui_rt_set_footer(&tf.rt, tpl_footer); + struct tui_form_field ff[3]; + memset(ff, 0, sizeof ff); + ff[0].label = "Namn"; + ff[0].value = tf.name; + ff[0].cap = sizeof tf.name; + ff[0].kind = TUI_F_TEXT; + ff[1].label = "Serie"; + ff[1].value = tf.series; + ff[1].cap = sizeof tf.series; + ff[1].kind = TUI_F_TEXT; + ff[2].label = "Text"; + ff[2].value = tf.desc; + ff[2].cap = sizeof tf.desc; + ff[2].kind = TUI_F_TEXT; + const char *title = load_name ? "Redigera mall" : "Ny mall"; + const char *hint = "Tab = byta fält F5 = validera ^X = rensa rad" + " ^Enter = spara Esc = avbryt"; + int stage = 0; for (;;) { - tui_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]); - tui_pad_field(padded, sizeof padded, w); - if (field == k) { - attron(A_REVERSE); - caret_y = hy[k]; - size_t slen = strlen(hvals[k]); - size_t cp = (field_fresh || field_pos == (size_t)-1 || - field_pos > slen) - ? slen - : field_pos; - int cw = (int)tui_disp_width_n(hvals[k], cp); - 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]); - tui_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)tui_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 : ""); - tui_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); - tui_hints("Tab = byta fält F5 = validera ^X = rensa rad ^Enter = 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_CTRL_X) { - 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_CTRL_ENTER || 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) { - tui_message("Mall", "%s", problem); + if (stage == 0) { + int r = tui_form_run(title, ff, 3, 1); + if (r == TUI_FORM_BACK) + return 0; + if (r == TUI_FORM_REFRESH) { + tpl_save(&tf, 1); 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); - char *args = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - const char *cmd = tpl_id ? "template.update" : "template.create"; - char *resp = ch == KEY_F(5) - ? rpc_dry(a, cmd, args) - : client_rpc(&a->conn, cmd, a->session, a->org, - args); - free(args); - if (resp && client_ok(resp)) { - if (ch == KEY_F(5)) { - tui_message("Validering OK", - "Mallen är korrekt (%d rader).", used); - } else { - tui_message("Mall", "Mallen '%s' sparad.", tname); - free(resp); - curs_set(0); + if (r == TUI_FORM_SUBMIT) { + if (tpl_save(&tf, 0)) return 1; - } - } else { - show_error("Kunde inte spara mallen", resp); + continue; } - 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; + stage = 1; } 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; - } + int rr = tui_rt_run(title, &tf.rt, hint); + if (rr == -1) { + stage = 0; + continue; + } + if (rr == -2) { + tpl_save(&tf, 1); + continue; } + if (rr == -3 && tpl_save(&tf, 0)) + return 1; } - if (!buf) - continue; - tui_field_edit(buf, cap, &field_pos, ch, &field_fresh); - trow_normalize(rows, &nrows, &field); - nfields = 3 + 3 * nrows; } } @@ -3926,39 +3669,6 @@ struct tui_ibrow { 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. */ /* Effective opening balances of the selected year: the year's IB vouchers plus all earlier movements, exactly what the reports show. */ @@ -4013,378 +3723,271 @@ static int64_t ib_old_lookup(char **accs, int64_t *amts, int n, return 0; } +struct ibform { + struct app *a; + struct tui_rt rt; + struct tui_ibrow rows[64]; + char **old_acc; + int64_t *old_amt; + int nold; +}; + +static void ib_cell(void *ud, int row, int col, char **buf, size_t *cap) +{ + struct ibform *ib = ud; + struct tui_ibrow *r = &ib->rows[row]; + if (col == 0) { + *buf = r->account; + *cap = sizeof r->account; + } else if (col == 2) { + *buf = r->amount; + *cap = sizeof r->amount; + } else { + *buf = r->text; + *cap = sizeof r->text; + } +} + +static void ib_info(void *ud, int row, char *buf, size_t n) +{ + struct ibform *ib = ud; + const char *nm = acct_name(ib->a, ib->rows[row].account); + snprintf(buf, n, "%s", nm ? nm : ""); +} + +static void ib_footer(void *ud, char *buf, size_t n) +{ + (void)ud; + snprintf(buf, n, "Positiva belopp = debet, negativa = kredit. Summan ska" + " bli noll."); +} + +static const char *ib_check(struct ibform *ib, int *used_out, char *msg, + size_t msglen) +{ + int64_t sum = 0; + int used = 0; + for (int i = 0; i < ib->rt.nrows; i++) { + if (!ib->rows[i].account[0]) + continue; + if (!acct_exists(ib->a, ib->rows[i].account)) { + snprintf(msg, msglen, "Konto %.15s finns inte (rad %d).", + ib->rows[i].account, i + 1); + return msg; + } + double kr = 0; + if (ib->rows[i].amount[0] && + !parse_x_double(ib->rows[i].amount, &kr)) { + snprintf(msg, msglen, "Ogiltigt belopp på rad %d: '%.20s'.", i + 1, + ib->rows[i].amount); + return msg; + } + sum += (int64_t)llround(kr * 100.0); + used++; + } + if (used == 0) + return "Ange minst ett konto."; + if (sum != 0) { + snprintf(msg, msglen, "Summan måste bli noll (nu %lld öre).", + (long long)sum); + return msg; + } + *used_out = used; + return NULL; +} + +/* Posts the delta against the existing IB so nothing is ever edited. */ +static int ib_post_delta(struct ibform *ib) +{ + struct app *a = ib->a; + 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", a->fy_start); + 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 < ib->rt.nrows; i++) { + if (!ib->rows[i].account[0]) + continue; + double kr = 0; + if (ib->rows[i].amount[0] && + !parse_x_double(ib->rows[i].amount, &kr)) + kr = 0; + int64_t new_amt = (int64_t)llround(kr * 100.0); + int64_t prev_amt = + ib_old_lookup(ib->old_acc, ib->old_amt, ib->nold, + ib->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", ib->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 (ib->rows[i].text[0]) + yyjson_mut_obj_add_strcpy(d, ro, "description", + ib->rows[i].text); + posted++; + } + for (int i = 0; i < ib->nold; i++) { + int still = 0; + for (int j = 0; j < ib->rt.nrows; j++) + if (strcmp(ib->rows[j].account, ib->old_acc[i]) == 0) { + still = 1; + break; + } + if (!still && ib->old_amt[i] != 0) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr); + yyjson_mut_obj_add_strcpy(d, ro, "account", ib->old_acc[i]); + yyjson_mut_obj_add_int(d, ro, "debit_ore", + ib->old_amt[i] < 0 ? -ib->old_amt[i] : 0); + yyjson_mut_obj_add_int(d, ro, "credit_ore", + ib->old_amt[i] > 0 ? ib->old_amt[i] : 0); + posted++; + } + } + if (posted == 0) { + yyjson_mut_doc_free(d); + tui_message("Ingående balans", "Ingen ändring."); + return 0; + } + yyjson_mut_obj_add_val(d, o, "rows", arr); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + char *resp = + client_rpc(&a->conn, "voucher.post", a->session, a->org, args); + free(args); + if (resp && client_ok(resp)) { + int64_t num = jint_val(resp, "result.number", 0); + tui_message("Ingående balans", + "Sparat som IB %lld (%d justerade rader).", + (long long)num, posted); + free(resp); + return 1; + } + show_error("Kunde inte spara ingående balans", resp); + free(resp); + return 0; +} + static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold) { - static struct tui_ibrow rows[64]; + struct ibform ib; + memset(&ib, 0, sizeof ib); + ib.a = a; + ib.old_acc = old_acc; + ib.old_amt = old_amt; + ib.nold = nold; if (nold > 63) { tui_message("Ingående balans", "För många konton (%d) för att redigera här.", nold); return 0; } - 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]); - tui_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; + snprintf(ib.rows[i].account, sizeof ib.rows[i].account, "%s", + old_acc[i]); + tui_kr_format(old_amt[i], ib.rows[i].amount, + sizeof ib.rows[i].amount); + } + int text_w = COLS - 51; if (text_w < 6) text_w = 6; - curs_set(1); + struct tui_rt_col cols[4] = { + { "Konto", 2, 6, TUI_RT_EDIT }, + { "Namn", 9, 24, TUI_RT_INFO }, + { "Belopp", 34, 14, TUI_RT_EDIT }, + { "Text", 49, text_w, TUI_RT_EDIT }, + }; + tui_rt_init(&ib.rt, nold > 0 ? nold : 1, 64, 4, cols, 5, ib_cell, ib_info, + &ib); + tui_rt_set_footer(&ib.rt, ib_footer); for (;;) { - tui_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]); - tui_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)tui_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 : ""); - tui_pad_field(nbuf, sizeof nbuf, name_w); - attron(A_DIM); - mvaddstr(y, x_name, nbuf); - attroff(A_DIM); - } - } - tui_hints("Tab = byta fält F5 = validera ^X = rensa rad ^Enter = spara Esc = avbryt"); - if (caret_y >= 0) - move(caret_y, caret_x); - refresh(); - - int ch = ui_getch(); - if (ch == 27) { - curs_set(0); + int rr = tui_rt_run("Ingående balans", &ib.rt, + "Tab = byta fält F5 = validera ^X = rensa rad" + " ^Enter = spara Esc = avbryt"); + if (rr == -1) 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; - } + char msg[256]; + int used = 0; + const char *problem = ib_check(&ib, &used, msg, sizeof msg); + if (problem) { + tui_message("Ingående balans", "%s", problem); continue; } - if (ch == KEY_CTRL_X) { - 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; - } + if (rr == -2) { + tui_message("Validering OK", "%d konton, summan är noll.", used); continue; } + if (ib_post_delta(&ib)) + return 1; + } +} - if (ch == KEY_F(5) || ch == KEY_CTRL_ENTER || 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) { - tui_message("Ingående balans", "%s", problem); - continue; - } - - if (ch == KEY_F(5)) { - tui_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); - tui_message("Ingående balans", "Ingen ändring."); - continue; - } - yyjson_mut_obj_add_val(d, o, "rows", arr); - char *args = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - char *resp = - client_rpc(&a->conn, "voucher.post", a->session, a->org, args); - free(args); - if (resp && client_ok(resp)) { - int64_t num = jint_val(resp, "result.number", 0); - tui_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; - } +struct ibpager { + struct app *a; + char **accs; + int64_t *amts; + int n; +}; - 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; - tui_field_edit(buf, cap, &field_pos, ch, &field_fresh); - ibrow_normalize(rows, &nrows, &field); - nfields = 3 * nrows; +static int ib_view_key(void *ud, int ch) +{ + struct ibpager *p = ud; + if (ch == 'e' || ch == 'E' || ch == TUI_KEY_CTRL_N) { + if (ib_form(p->a, p->accs, p->amts, p->n)) + return TUI_HOOK_REFRESH; + return TUI_HOOK_STAY; } + return TUI_HOOK_NONE; } +/* The list is a read-only table with no selection: tui_pager shows the + columns and the sum as plain text, and the e/^N keys run the editor. */ 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; - tui_frame("Ingående balans"); - attron(A_BOLD); - mvprintw(2, 2, "Räkenskapsår %s ingående balans (effektiv)", + char **accs = NULL; + int64_t *amts = NULL; + int n = 0; + if (ib_load(a, &accs, &amts, &n) != 0) + return; + struct buf t; + buf_init(&t); + buf_line(&t, "Räkenskapsår %s ingående balans (effektiv)\n\n", a->fy_label); - mvaddstr(4, 2, "Konto"); - mvaddstr(4, 9, "Namn"); - mvaddstr(4, 62, "Belopp"); - attroff(A_BOLD); + buf_line(&t, "%-7s%-53s%s\n\n", "Konto", "Namn", "Belopp"); int64_t sum = 0; - for (int i = 0; i < n; i++) + for (int i = 0; i < n; i++) { sum += amts[i]; - 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]; - tui_kr_format(amts[idx], amount, sizeof amount); - const char *nm = acct_name(a, accs[idx]); - char nbuf[128]; + char amount[32], nbuf[128]; + tui_kr_format(amts[i], amount, sizeof amount); + const char *nm = acct_name(a, accs[i]); snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : ""); tui_pad_field(nbuf, sizeof nbuf, 50); - mvprintw(6 + i, 2, "%-6s %s %14s", accs[idx], nbuf, amount); + buf_line(&t, "%-6s %s %14s\n", accs[i], nbuf, amount); } + if (n == 0) + buf_line(&t, "Ingen ingående balans registrerad ännu.\n"); char sumbuf[32]; tui_kr_format(sum, sumbuf, sizeof sumbuf); - attron(A_BOLD); - mvprintw(LINES - 4, 2, "Summa: %s", sumbuf); - attroff(A_BOLD); - tui_hints("e/^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); + buf_line(&t, "\nSumma: %s\n", sumbuf); + buf_append(&t, "\0", 1); + struct ibpager p = { a, accs, amts, n }; + int ret = tui_pager_hook("Ingående balans", (const char *)t.p, + "e/^N = redigera", 0, ib_view_key, &p); + buf_free(&t); + for (int i = 0; i < n; i++) + free(accs[i]); + free(accs); + free(amts); + if (ret == 0) return; - } } } @@ -5950,94 +5553,36 @@ static int login_screen(struct app *a) const char *env_pass = getenv("BOKFD_PASSWORD"); snprintf(user, sizeof user, "%s", a->username); snprintf(pass, sizeof pass, "%s", env_pass ? env_pass : ""); - int field = 3; /* 0 server, 1 user, 2 password, 3 = Logga in button */ + struct tui_form_field ff[4]; + memset(ff, 0, sizeof ff); + ff[0].label = "Server"; + ff[0].value = socket_path; + ff[0].cap = sizeof socket_path; + ff[0].kind = TUI_F_TEXT; + ff[1].label = "Användare"; + ff[1].value = user; + ff[1].cap = sizeof user; + ff[1].kind = TUI_F_TEXT; + ff[2].label = "Lösenord"; + ff[2].value = pass; + ff[2].cap = sizeof pass; + ff[2].kind = TUI_F_TEXT; + ff[2].mask = 1; + ff[3].label = ""; + ff[3].value = "Logga in"; + ff[3].kind = TUI_F_ACTION; for (;;) { - tui_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 = tui_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); - } - tui_hints("Tab = byta fält/knapp Enter = logga in ^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; + int r = tui_form_run("bokf — inloggning", ff, 4, 1); + if (r == TUI_FORM_BACK) { + if (g_quit) { + endwin(); + return -1; } - 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 = tui_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 */ + continue; /* Esc never exits here */ } + if (r != TUI_FORM_SUBMIT && r != 3) + continue; /* attempt login */ snprintf(a->socket, sizeof a->socket, "%s", socket_path); @@ -6212,9 +5757,12 @@ int main(int argc, char **argv) app.username[0] && env_pass && *env_pass ? 1 : 0); for (int i = 0; i < 60 && !app.session[0]; i++) { if (i > 0) { - mvprintw(LINES / 2, 2, "Återansluter till servern... (%ds)", - i); - refresh(); + char msg[128]; + snprintf(msg, sizeof msg, + "Återansluter till servern... (%ds)", i); + tui_set_status(msg, NULL); + tui_frame("bokf — ansluter"); + tui_redraw(); sleep(1); } if (session_arg && *session_arg) { diff --git a/clients/tui.c b/clients/tui.c index d19bd17..164b47b 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -689,8 +689,16 @@ int tui_choice_prompt(const char *label, const char *const *opts, int n, int tui_nav_key(struct tui_list_nav *v, int ch, int allow_new, int allow_remove, int allow_toggle, int menu_mode) { - if (v->n <= 0) - return -3; + if (v->n <= 0) { + /* an empty list is never a dead end: refresh, add and back work */ + if (ch == KEY_F(5)) + return -2; + if (ch == TUI_KEY_CTRL_N && allow_new) + return -4; + if (ch == 27 || ch == 'q') + return -1; + return TUI_NAV_NONE; + } if (v->goto_active) { if (ch >= '0' && ch <= '9' && strlen(v->gotobuf) < 9) { size_t gl = strlen(v->gotobuf); @@ -806,6 +814,16 @@ static void list_view_sync(struct tui_list_nav *v) int tui_select_list(const char *title, char **items, int n, int start, int allow_refresh, int *cursor, int allow_new, const char *archive_action, int allow_toggle) +{ + return tui_select_list_hook(title, items, n, start, allow_refresh, cursor, + allow_new, archive_action, allow_toggle, NULL, + NULL); +} + +int tui_select_list_hook(const char *title, char **items, int n, int start, + int allow_refresh, int *cursor, int allow_new, + const char *archive_action, int allow_toggle, + int (*key)(void *ud, int ch), void *ud) { struct tui_list_nav v; memset(&v, 0, sizeof v); @@ -833,11 +851,21 @@ int tui_select_list(const char *title, char **items, int n, int start, allow_new ? " ^N = ny" : "", dbuf, allow_toggle ? " ^A = öppna/stäng" : ""); list_draw(title, items, n, &v, hint); - int r = tui_nav_key(&v, in_key(), allow_new, - archive_action != NULL, allow_toggle, 0); + int ch = in_key(); + int r = tui_nav_key(&v, ch, allow_new, archive_action != NULL, + allow_toggle, 0); + if (r == TUI_NAV_NONE && key) { + int h = key(ud, ch); + if (h == TUI_HOOK_BACK) + return -1; + if (h == TUI_HOOK_REFRESH) + return -2; + if (h != TUI_HOOK_NONE) + continue; + } if (r == -2 && !allow_refresh) continue; - if (r != -3) + if (r != TUI_NAV_NONE) return r; } } @@ -926,6 +954,12 @@ void tui_frame(const char *title) attroff(A_DIM); } +/* Flush a frame drawn by the application outside a widget loop. */ +void tui_redraw(void) +{ + refresh(); +} + void tui_hints(const char *s) { char line[512]; @@ -941,6 +975,14 @@ void tui_hints(const char *s) /* ------------------------------------------------------------------ */ int tui_pager(const char *title, const char *text, const char *action_hint) +{ + return tui_pager_hook(title, text, action_hint, + action_hint ? TUI_PAGER_SAVE : 0, NULL, NULL); +} + +int tui_pager_hook(const char *title, const char *text, + const char *extra_hint, unsigned flags, + int (*key)(void *ud, int ch), void *ud) { int ret = 0; int nlines = 0; @@ -970,12 +1012,12 @@ int tui_pager(const char *title, const char *text, const char *action_hint) if (top + i < n) mvaddnstr(2 + i, 2, lines[top + i], COLS - 4); } - char hint[256]; - if (action_hint) + char hint[512]; + if (extra_hint && *extra_hint) snprintf(hint, sizeof hint, "piltangenter/PgUp/PgDn rullar F5 = uppdatera %s " "q = tillbaka", - action_hint); + extra_hint); else snprintf(hint, sizeof hint, "piltangenter/PgUp/PgDn rullar F5 = uppdatera " @@ -989,10 +1031,21 @@ int tui_pager(const char *title, const char *text, const char *action_hint) ret = 1; break; } - if (ch == 's' && action_hint) { + if ((flags & TUI_PAGER_SAVE) && ch == 's') { ret = 2; break; } + if (key) { + int h = key(ud, ch); + if (h == TUI_HOOK_BACK) + break; + if (h == TUI_HOOK_REFRESH) { + ret = 1; + break; + } + if (h == TUI_HOOK_STAY) + continue; + } if (ch == KEY_DOWN && top + view < n) top++; else if (ch == KEY_UP && top > 0) @@ -1039,6 +1092,12 @@ int tui_form_next(int sel, int n, int ch) int tui_form_run(const char *title, struct tui_form_field *f, int n, int can_edit) +{ + return tui_form_run_hook(title, f, n, can_edit, NULL, NULL); +} + +int tui_form_run_hook(const char *title, struct tui_form_field *f, int n, + int can_edit, int (*key)(void *ud, int ch), void *ud) { int sel = 0; for (;;) { @@ -1056,7 +1115,8 @@ int tui_form_run(const char *title, struct tui_form_field *f, int n, snprintf(lbl, sizeof lbl, "%s", f[i].label); tui_pad_field(lbl, sizeof lbl, 31); if (f[i].kind == TUI_F_ACTION) { - snprintf(val, sizeof val, "(lista)"); + snprintf(val, sizeof val, "%s", + f[i].value ? f[i].value : "(lista)"); } else if (f[i].kind == TUI_F_AMOUNT && f[i].ore) { tui_kr_format(*f[i].ore, val, sizeof val); } else if (f[i].kind == TUI_F_CHOICE && f[i].choice && @@ -1064,6 +1124,12 @@ int tui_form_run(const char *title, struct tui_form_field *f, int n, int c = *f[i].choice; snprintf(val, sizeof val, "%s", c >= 0 && c < f[i].nchoices ? f[i].choices[c] : ""); + } else if (f[i].mask && f[i].kind == TUI_F_TEXT) { + size_t vl = f[i].value ? strlen(f[i].value) : 0; + if (vl >= sizeof val) + vl = sizeof val - 1; + memset(val, '*', vl); + val[vl] = '\0'; } else { snprintf(val, sizeof val, "%s", f[i].value ? f[i].value : ""); @@ -1094,6 +1160,17 @@ int tui_form_run(const char *title, struct tui_form_field *f, int n, if (nxt == TUI_FORM_REFRESH || nxt == TUI_FORM_SUBMIT || nxt == TUI_FORM_BACK) return nxt; + if (key) { + int h = key(ud, ch); + if (h == TUI_HOOK_BACK) + return TUI_FORM_BACK; + if (h == TUI_HOOK_REFRESH) + return TUI_FORM_REFRESH; + if (h == TUI_HOOK_SUBMIT) + return TUI_FORM_SUBMIT; + if (h != TUI_HOOK_NONE) + continue; + } if (ch != '\n' && ch != '\r' && ch != KEY_ENTER) continue; if (!can_edit) { @@ -1107,7 +1184,8 @@ int tui_form_run(const char *title, struct tui_form_field *f, int n, snprintf(label, sizeof label, "%s: ", fl->label); if (fl->kind == TUI_F_TEXT) { if (fl->value && - tui_prompt_into(fl->value, fl->cap, label, fl->value, 0)) + tui_prompt_into(fl->value, fl->cap, label, fl->value, + fl->mask)) return sel; } else if (fl->kind == TUI_F_DATE) { if (fl->value && tui_date_prompt_into(fl->value, fl->cap, label, @@ -1153,6 +1231,32 @@ void tui_rt_init(struct tui_rt *t, int nrows, int maxrows, int ncols, t->pos = (size_t)-1; } +void tui_rt_set_key(struct tui_rt *t, int (*key)(void *ud, int ch)) +{ + t->key = key; +} + +void tui_rt_set_footer(struct tui_rt *t, + void (*footer)(void *ud, char *buf, size_t n)) +{ + t->footer = footer; +} + +/* Actual column index of the ecol-th editable column. Cell callbacks always + receive the actual column index, also when INFO columns are interleaved. */ +int tui_rt_col_of(const struct tui_rt *t, int ecol) +{ + int ei = 0; + for (int i = 0; i < t->ncols; i++) { + if (t->cols[i].kind == TUI_RT_INFO) + continue; + if (ei == ecol) + return i; + ei++; + } + return 0; +} + static void rt_get(struct tui_rt *t, int row, int col, char **buf, size_t *cap) { t->cell(t->ud, row, col, buf, cap); @@ -1208,6 +1312,19 @@ int tui_rt_move(struct tui_rt *t, int dr, int dc) return moved; } +/* Tab/Shift-Tab: next/previous editable cell, wrapping to the next row. */ +int tui_rt_tab(struct tui_rt *t, int dir) +{ + if (t->neditable <= 0) + return 0; + int moved = tui_rt_move(t, 0, dir); + if (dir > 0 && t->col == 0) + moved |= tui_rt_move(t, 1, 0); + else if (dir < 0 && t->col == t->neditable - 1) + moved |= tui_rt_move(t, -1, 0); + return moved; +} + int tui_rt_clear(struct tui_rt *t) { if (t->row < 0 || t->row >= t->nrows) @@ -1232,10 +1349,24 @@ int tui_rt_clear(struct tui_rt *t) int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) { int top = 0; + int prev_cursor = curs_set(1); for (;;) { if (g_frame) g_frame(title); - int view = LINES - 4 - t->y; + char foot[2048]; + int nfoot = 0; + foot[0] = '\0'; + if (t->footer) { + t->footer(t->ud, foot, sizeof foot); + for (char *p = foot; *p;) { + nfoot++; + char *nl = strchr(p, '\n'); + if (!nl) + break; + p = nl + 1; + } + } + int view = LINES - 4 - t->y - nfoot; if (view < 1) view = 1; if (t->row < top) @@ -1300,24 +1431,66 @@ int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) } } } + if (nfoot > 0) { + int fy = t->y + (t->nrows - top < view ? t->nrows - top : view); + if (fy + nfoot > LINES - 2) + fy = LINES - 2 - nfoot; + if (fy < t->y) + fy = t->y; + char *p = foot; + for (int i = 0; i < nfoot && fy + i < LINES - 2; i++) { + char *nl = strchr(p, '\n'); + if (nl) + *nl = '\0'; + move(fy + i, 2); + clrtoeol(); + attron(A_DIM); + addnstr(p, COLS - 4); + attroff(A_DIM); + p = nl ? nl + 1 : p + strlen(p); + } + } if (g_hints) g_hints(hint); if (caret_y >= 0) move(caret_y, caret_x); refresh(); int ch = in_key(); - if (ch == 27 || ch == 'q') + if (ch == 27 || ch == 'q') { + curs_set(prev_cursor == ERR ? 0 : prev_cursor); return -1; - if (ch == KEY_F(5)) + } + if (ch == KEY_F(5)) { + curs_set(prev_cursor == ERR ? 0 : prev_cursor); return -2; - if (ch == TUI_KEY_CTRL_ENTER || ch == KEY_F(9)) + } + if (ch == TUI_KEY_CTRL_ENTER || ch == KEY_F(9)) { + curs_set(prev_cursor == ERR ? 0 : prev_cursor); return -3; + } + if (t->key) { + int h = t->key(t->ud, ch); + if (h == TUI_HOOK_BACK) { + curs_set(prev_cursor == ERR ? 0 : prev_cursor); + return -1; + } + if (h == TUI_HOOK_REFRESH) { + curs_set(prev_cursor == ERR ? 0 : prev_cursor); + return -2; + } + if (h == TUI_HOOK_SUBMIT) { + curs_set(prev_cursor == ERR ? 0 : prev_cursor); + return -3; + } + if (h == TUI_HOOK_STAY) + continue; + } if (ch == '\t') { - tui_rt_move(t, 0, 1); + tui_rt_tab(t, 1); continue; } if (ch == KEY_BTAB) { - tui_rt_move(t, 0, -1); + tui_rt_tab(t, -1); continue; } if (ch == KEY_UP) { @@ -1332,12 +1505,13 @@ int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) tui_rt_clear(t); continue; } + int ci = tui_rt_col_of(t, t->col); char *buf = NULL; size_t cap = 0; - rt_get(t, t->row, t->col, &buf, &cap); + rt_get(t, t->row, ci, &buf, &cap); if (!buf) continue; - if (t->cols[t->col].kind == TUI_RT_DATE) + if (t->cols[ci].kind == TUI_RT_DATE) tui_date_field_edit(buf, cap, &t->pos, ch, &t->fresh); else tui_field_edit(buf, cap, &t->pos, ch, &t->fresh); diff --git a/clients/tui.h b/clients/tui.h index d41d58c..39ea913 100644 --- a/clients/tui.h +++ b/clients/tui.h @@ -15,6 +15,13 @@ #define TUI_KEY_CTRL_X 0x18 #define TUI_KEY_CTRL_ENTER (KEY_MAX + 1) +/* Key hooks: widgets call the screen's hook for keys they do not handle. */ +#define TUI_HOOK_NONE 0 /* not handled: process the key normally */ +#define TUI_HOOK_STAY 1 /* handled: redraw and keep the widget up */ +#define TUI_HOOK_BACK 2 /* handled: return "back" */ +#define TUI_HOOK_REFRESH 3 /* handled: return "refresh" */ +#define TUI_HOOK_SUBMIT 4 /* handled: return "submit" (forms only) */ + /* The application supplies input, frame/hints drawing and a quit hook so this module stays free of app state (and testable). */ void tui_set_input(int (*fn)(void)); @@ -92,6 +99,10 @@ int tui_nav_key(struct tui_list_nav *v, int ch, int allow_new, int tui_select_list(const char *title, char **items, int n, int start, int allow_refresh, int *cursor, int allow_new, const char *archive_action, int allow_toggle); +int tui_select_list_hook(const char *title, char **items, int n, int start, + int allow_refresh, int *cursor, int allow_new, + const char *archive_action, int allow_toggle, + int (*key)(void *ud, int ch), void *ud); int tui_menu(const char *title, const char *const *items, int n, int allow_new, int *cursor); @@ -99,7 +110,12 @@ int tui_menu(const char *title, const char *const *items, int n, void tui_set_status(const char *status, const char *right); void tui_frame(const char *title); void tui_hints(const char *s); +void tui_redraw(void); +#define TUI_PAGER_SAVE 0x1 /* 's' returns 2 (the save action) */ int tui_pager(const char *title, const char *text, const char *action_hint); +int tui_pager_hook(const char *title, const char *text, + const char *extra_hint, unsigned flags, + int (*key)(void *ud, int ch), void *ud); /* --- forms --- */ enum { @@ -112,13 +128,14 @@ enum { #define TUI_FORM_BACK (-1) #define TUI_FORM_REFRESH (-2) -#define TUI_FORM_SUBMIT (-3) +#define TUI_FORM_SUBMIT (-8) struct tui_form_field { const char *label; - char *value; /* text storage for TEXT/DATE (also CHOICE labels) */ + char *value; /* text storage for TEXT/DATE (also CHOICE labels/ACTION) */ size_t cap; int kind; + int mask; /* TEXT: '*' instead of the content */ int64_t *ore; /* TUI_F_AMOUNT */ const char *const *choices; /* TUI_F_CHOICE */ int nchoices; @@ -128,6 +145,8 @@ struct tui_form_field { int tui_form_next(int sel, int n, int ch); int tui_form_run(const char *title, struct tui_form_field *f, int n, int can_edit); +int tui_form_run_hook(const char *title, struct tui_form_field *f, int n, + int can_edit, int (*key)(void *ud, int ch), void *ud); /* --- row table --- */ enum { TUI_RT_EDIT = 0, TUI_RT_INFO, TUI_RT_DATE }; @@ -149,16 +168,24 @@ struct tui_rt { void (*cell)(void *, int, int, char **, size_t *); void (*info)(void *, int, char *, size_t); void *ud; + int (*key)(void *, int); + /* Writes the dim status lines shown under the table ('\n' separated). */ + void (*footer)(void *, char *, size_t); }; void tui_rt_init(struct tui_rt *t, int nrows, int maxrows, int ncols, const struct tui_rt_col *cols, int y, void (*cell)(void *, int, int, char **, size_t *), void (*info)(void *, int, char *, size_t), void *ud); +void tui_rt_set_key(struct tui_rt *t, int (*key)(void *ud, int ch)); +void tui_rt_set_footer(struct tui_rt *t, + void (*footer)(void *ud, char *buf, size_t n)); int tui_rt_blank_row(struct tui_rt *t, int row); int tui_rt_normalize(struct tui_rt *t); int tui_rt_move(struct tui_rt *t, int dr, int dc); +int tui_rt_tab(struct tui_rt *t, int dir); int tui_rt_clear(struct tui_rt *t); +int tui_rt_col_of(const struct tui_rt *t, int ecol); int tui_rt_run(const char *title, struct tui_rt *t, const char *hint); #endif -- cgit v1.3