diff options
| -rw-r--r-- | Makefile | 7 | ||||
| -rw-r--r-- | clients/bokftui.c | 5 | ||||
| -rw-r--r-- | clients/screens_vouchers.c | 351 | ||||
| -rw-r--r-- | clients/tui.c | 14 | ||||
| -rw-r--r-- | clients/tui.h | 3 | ||||
| -rw-r--r-- | clients/ui.h | 1 | ||||
| -rw-r--r-- | clients/vlist.c | 84 | ||||
| -rw-r--r-- | clients/vlist.h | 46 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 5 | ||||
| -rw-r--r-- | docs/STATE.md | 7 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 6 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 46 | ||||
| -rw-r--r-- | tests/test_tui.c | 56 |
13 files changed, 517 insertions, 114 deletions
@@ -58,7 +58,8 @@ TUI_SCREEN_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(TUI_SCREENS)) TUI_SCREEN_DEP = $(TUI_SCREEN_OBJ:.o=.d) $(BUILD)/bokftui: $(BUILD)/clients/bokftui.o $(BUILD)/clients/ui.o \ - $(BUILD)/clients/drafts.o $(TUI_SCREEN_OBJ) \ + $(BUILD)/clients/drafts.o $(BUILD)/clients/vlist.o \ + $(TUI_SCREEN_OBJ) \ $(BUILD)/clients/tui.o \ $(BUILD)/clients/client.o \ $(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/src/formula.o \ @@ -70,7 +71,7 @@ $(BUILD)/test_core: $(BUILD)/tests/test_core.o $(BUILD)/clients/client.o \ $(CC) $(CFLAGS) -o $@ $^ -lm $(SSL_LIBS) $(BUILD)/test_tui: $(BUILD)/tests/test_tui.o $(BUILD)/clients/tui.o \ - $(BUILD)/clients/drafts.o \ + $(BUILD)/clients/drafts.o $(BUILD)/clients/vlist.o \ $(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/vendor/yyjson.o \ $(BUILD)/vendor/sha256.o $(CC) $(CFLAGS) -o $@ $^ -lm -lncursesw @@ -158,7 +159,7 @@ $(BUILD)/tests/%.o: tests/%.c $(BUILD)/clients/bokfctl.d $(BUILD)/clients/bokftui.d \ $(BUILD)/clients/ui.d $(TUI_SCREEN_DEP) \ $(BUILD)/clients/tui.d $(BUILD)/clients/client.d \ - $(BUILD)/clients/drafts.d \ + $(BUILD)/clients/drafts.d $(BUILD)/clients/vlist.d \ $(BUILD)/tests/test_core.d $(BUILD)/tests/test_tui.d \ $(BUILD)/tests/pdf_check.d $(BUILD)/tests/invoice_check.d \ $(BUILD)/tests/smtp_check.d \ diff --git a/clients/bokftui.c b/clients/bokftui.c index 69d6690..867e63e 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -20,6 +20,7 @@ #include "version.h" #include "yyjson.h" #include "ui.h" +#include "vlist.h" /* Screen names for ^R restoration and --screen. Only the top-level views; sub-screens unwind to their parent. */ @@ -149,6 +150,8 @@ static void config_load(struct app *a) else if (strcmp(line, "attachment_dir") == 0 && !a->attachment_dir[0]) snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", val); + else if (strcmp(line, "voucher_sort") == 0) + a->voucher_sort = vlist_sort_parse(val); } fclose(f); } @@ -166,6 +169,8 @@ void config_save(struct app *a) fprintf(f, "user=%s\n", a->username); if (a->attachment_dir[0]) fprintf(f, "attachment_dir=%s\n", a->attachment_dir); + if (a->voucher_sort != VSORT_NUMBER) + fprintf(f, "voucher_sort=%s\n", vlist_sort_name(a->voucher_sort)); fclose(f); chmod(path, 0600); } diff --git a/clients/screens_vouchers.c b/clients/screens_vouchers.c index 4cb6d9d..3c33990 100644 --- a/clients/screens_vouchers.c +++ b/clients/screens_vouchers.c @@ -20,6 +20,7 @@ #include "version.h" #include "yyjson.h" #include "ui.h" +#include "vlist.h" struct vdctx { struct app *a; @@ -30,12 +31,21 @@ struct vdctx { int want_refresh; int posted_new; int64_t posted_id; + int can_prev, can_next; /* a neighbour exists in the list order */ + int nav; /* -1/+1: show the previous/next voucher */ }; static int vd_key(void *ud, int ch) { struct vdctx *ctx = ud; struct app *a = ctx->a; + if (ch == KEY_UP || ch == KEY_DOWN) { + int dir = ch == KEY_UP ? -1 : 1; + if (!(dir < 0 ? ctx->can_prev : ctx->can_next)) + return TUI_HOOK_STAY; + ctx->nav = dir; + return TUI_HOOK_BACK; + } if (ch == TUI_KEY_CTRL_N) { int64_t nid = vouchers_new(a); if (nid > 0) { @@ -175,10 +185,48 @@ static int vd_key(void *ud, int ch) return TUI_HOOK_NONE; } -static int voucher_detail(struct app *a, int64_t id, int64_t *out_new) +/* Column widths of the voucher detail; header and rows share them. */ +enum { VD_ACCT_W = 6, VD_NAME_W = 40, VD_AMT_W = 14 }; + +static void vd_amount(int64_t ore, char *buf, size_t n) +{ + if (ore) + tui_kr_format(ore, buf, n); + else + buf[0] = '\0'; +} + +/* One detail line: " konto benämning debet kredit text". Every column + is padded by display width so åäö never shift the ones after it. */ +static void vd_line(struct buf *out, const char *mark, const char *acct, + const char *name, const char *debit, const char *credit, + const char *text) +{ + char acol[64], ncol[256], dcol[64], ccol[64], line[1024]; + snprintf(acol, sizeof acol, "%s", acct); + tui_pad_field(acol, sizeof acol, VD_ACCT_W); + snprintf(ncol, sizeof ncol, "%s", name); + tui_pad_field(ncol, sizeof ncol, VD_NAME_W); + tui_pad_hdr(dcol, sizeof dcol, VD_AMT_W, debit); + tui_pad_hdr(ccol, sizeof ccol, VD_AMT_W, credit); + int n = snprintf(line, sizeof line, "%s %s %s %s %s %s", mark, acol, + ncol, dcol, ccol, text); + while (n > 0 && line[n - 1] == ' ') + n--; + buf_append(out, line, (size_t)n); + buf_append(out, "\n", 1); +} + +/* Returns 0 back, 1 after a correction, 2 when a new voucher was posted + (*out_new), 3 to show the neighbour in *nav (-1/+1) in the list order. + pos/total place the voucher in that order (pos -1: not in the list). */ +static int voucher_detail(struct app *a, int64_t id, int64_t *out_new, + int pos, int total, int *nav) { if (out_new) *out_new = 0; + if (nav) + *nav = 0; char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id); for (;;) { @@ -201,34 +249,38 @@ static int voucher_detail(struct app *a, int64_t id, int64_t *out_new) series ? series : "", (long long)number, date ? date : "", desc ? desc : ""); buf_append(&text, line, strlen(line)); - int hdr = snprintf( - line, sizeof line, MK_HEAD " %-6s %-34s D %12s K %12s\n", - "Konto", "Benämning", "Debet", "Kredit"); - buf_append(&text, line, (size_t)hdr); + vd_line(&text, MK_HEAD, "Konto", "Benämning", "Debet", "Kredit", + "Text"); size_t nrows = jarr_size(resp, "result.rows"); + int64_t sum_d = 0, sum_c = 0; for (size_t i = 0; i < nrows; i++) { char path[64]; snprintf(path, sizeof path, "result.rows.%zu.account", i); char *acc = jstr_dup(resp, path); snprintf(path, sizeof path, "result.rows.%zu.name", i); char *name = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.rows.%zu.description", i); + char *rtext = jstr_dup(resp, path); snprintf(path, sizeof path, "result.rows.%zu.debit_ore", i); int64_t debit = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.rows.%zu.credit_ore", i); int64_t credit = jint_val(resp, path, 0); + sum_d += debit; + sum_c += credit; char d[32], c[32]; - tui_kr_format(debit, d, sizeof d); - tui_kr_format(credit, c, sizeof c); - char acol[32], ncol[256]; - snprintf(acol, sizeof acol, "%s", acc ? acc : ""); - tui_pad_field(acol, sizeof acol, 6); - snprintf(ncol, sizeof ncol, "%s", name ? name : ""); - tui_pad_field(ncol, sizeof ncol, 34); - snprintf(line, sizeof line, " %s %s D %12s K %12s\n", acol, - ncol, d, c); - buf_append(&text, line, strlen(line)); + vd_amount(debit, d, sizeof d); + vd_amount(credit, c, sizeof c); + vd_line(&text, "", acc ? acc : "", name ? name : "", d, c, + rtext ? rtext : ""); free(acc); free(name); + free(rtext); + } + { + char d[32], c[32]; + tui_kr_format(sum_d, d, sizeof d); + tui_kr_format(sum_c, c, sizeof c); + vd_line(&text, MK_DIM, "", "Summa", d, c, ""); } size_t natts = jarr_size(resp, "result.attachments"); if (natts) { @@ -267,13 +319,22 @@ static int voucher_detail(struct app *a, int64_t id, int64_t *out_new) ctx.id = id; ctx.natts = natts; ctx.resp = resp; - char hint[256]; + ctx.can_prev = pos > 0; + ctx.can_next = pos >= 0 && pos + 1 < total; + char hint[256], title[96]; snprintf(hint, sizeof hint, - "^N = nytt verifikat c = rätta ^F = bifoga%s", + "%s^N = nytt verifikat c = rätta ^F = bifoga%s", + pos >= 0 ? "upp/ned = föregående/nästa " : "", natts ? " f = underlag" : ""); + if (pos >= 0) + snprintf(title, sizeof title, "Verifikat %s%lld (%d av %d)", + series ? series : "", (long long)number, pos + 1, total); + else + snprintf(title, sizeof title, "Verifikat %s%lld", + series ? series : "", (long long)number); int want_refresh = 0, corrected = 0; - int ret = tui_pager_hook("Verifikat", (const char *)text.p, hint, 0, - vd_key, &ctx); + int ret = tui_pager_hook(title, (const char *)text.p, hint, + TUI_PAGER_NO_ARROWS, vd_key, &ctx); if (ret == 1) want_refresh = 1; if (ctx.want_refresh) @@ -294,6 +355,11 @@ static int voucher_detail(struct app *a, int64_t id, int64_t *out_new) } if (want_refresh) continue; + if (ctx.nav && !corrected) { + if (nav) + *nav = ctx.nav; + return 3; + } return corrected; } } @@ -882,99 +948,167 @@ int64_t vouchers_new_prefill(struct app *a, const struct voucher_prefill *p) } } +struct vlctx { + struct app *a; + int resort; +}; + +static int vlist_key(void *ud, int ch) +{ + struct vlctx *l = ud; + if (ch != 's') + return TUI_HOOK_NONE; + l->a->voucher_sort = (l->a->voucher_sort + 1) % VSORT_COUNT; + config_save(l->a); + l->resort = 1; + return TUI_HOOK_REFRESH; +} + +static void vlist_free(struct vlist_item *v, size_t n) +{ + for (size_t i = 0; i < n; i++) + free(v[i].desc); + free(v); +} + +/* All vouchers of the current year, page by page. NULL after an error + (already shown). */ +static struct vlist_item *vlist_fetch(struct app *a, size_t *out_n) +{ + struct vlist_item *v = NULL; + size_t n = 0, cap = 0; + int64_t cursor = 0; + for (;;) { + char largs[128]; + snprintf(largs, sizeof largs, + "{\"limit\":1000,\"fiscal_year\":%lld,\"cursor\":%lld}", + (long long)a->fy, (long long)cursor); + char *resp = client_rpc(&a->conn, "voucher.list", a->session, a->org, + largs); + if (!resp || !client_ok(resp)) { + show_error("Verifikat", resp); + free(resp); + vlist_free(v, n); + return NULL; + } + size_t m = jarr_size(resp, "result.items"); + if (n + m > cap) { + cap = (n + m) * 2; + v = xrealloc(v, cap * sizeof *v); + } + for (size_t i = 0; i < m; i++) { + struct vlist_item *it = &v[n + i]; + memset(it, 0, sizeof *it); + char path[64]; + snprintf(path, sizeof path, "result.items.%zu.id", i); + it->id = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.number", i); + it->number = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.attachment_count", + i); + it->attachments = (int)jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.series", i); + char *ser = jstr_dup(resp, path); + snprintf(it->series, sizeof it->series, "%s", ser ? ser : ""); + free(ser); + snprintf(path, sizeof path, "result.items.%zu.date", i); + char *date = jstr_dup(resp, path); + snprintf(it->date, sizeof it->date, "%s", date ? date : ""); + free(date); + snprintf(path, sizeof path, "result.items.%zu.description", i); + it->desc = jstr_dup(resp, path); + } + n += m; + int64_t next = jint_val(resp, "result.next_cursor", 0); + free(resp); + if (!next || m == 0) + break; + cursor = next; + } + *out_n = n; + return v ? v : xcalloc(1, sizeof *v); +} + +/* List lines for v (plus the trailing "new" row); the caller frees them. */ +static char **vlist_lines(const struct vlist_item *v, size_t n) +{ + int idw = 4; + for (size_t i = 0; i < n; i++) { + char idbuf[48]; + int l = snprintf(idbuf, sizeof idbuf, "%s%lld", v[i].series, + (long long)v[i].number); + if (l > idw) + idw = l; + } + char **items = xcalloc(n + 1, sizeof(char *)); + for (size_t i = 0; i < n; i++) { + char idbuf[48], line[512]; + snprintf(idbuf, sizeof idbuf, "%s%lld", v[i].series, + (long long)v[i].number); + snprintf(line, sizeof line, " %c %-*s %s %s", + v[i].attachments ? 'x' : ' ', idw, idbuf, v[i].date, + v[i].desc ? v[i].desc : ""); + items[i] = xstrdup(line); + } + items[n] = xstrdup("+ Nytt verifikat (^N)"); + return items; +} + +static void vlist_lines_free(char **items, size_t n) +{ + for (size_t i = 0; items && i <= n; i++) + free(items[i]); + free(items); +} + void vouchers_screen(struct app *a) { + struct vlist_item *v = NULL; char **items = NULL; - int64_t *ids = NULL; size_t n = 0; - int fetch = 1; + int fetch = 1, resort = 0; for (;;) { - if (g_quit) { - for (size_t i = 0; i < n; i++) - free(items[i]); - free(items); - free(ids); - return; - } + if (g_quit) + break; if (fetch) { - for (size_t i = 0; i < n; i++) - free(items[i]); - free(items); - free(ids); + vlist_lines_free(items, n); + vlist_free(v, n); items = NULL; - ids = NULL; n = 0; - fetch = 0; - char largs[96]; - snprintf(largs, sizeof largs, - "{\"limit\":200,\"fiscal_year\":%lld}", - (long long)a->fy); - char *resp = client_rpc(&a->conn, "voucher.list", a->session, a->org, - largs); - if (!resp || !client_ok(resp)) { - show_error("Verifikat", resp); - free(resp); + v = vlist_fetch(a, &n); + if (!v) return; - } - n = jarr_size(resp, "result.items"); - items = xcalloc(n + 1, sizeof(char *)); - ids = xcalloc(n ? n : 1, sizeof(int64_t)); - char **idstr = xcalloc(n ? n : 1, sizeof(char *)); - int idw = 4; - for (size_t i = 0; i < n; i++) { - char path[64]; - snprintf(path, sizeof path, "result.items.%zu.id", i); - ids[i] = jint_val(resp, path, 0); - snprintf(path, sizeof path, "result.items.%zu.series", i); - char *ser = jstr_dup(resp, path); - snprintf(path, sizeof path, "result.items.%zu.number", i); - int64_t number = jint_val(resp, path, 0); - char idbuf[32]; - snprintf(idbuf, sizeof idbuf, "%s%lld", ser ? ser : "", - (long long)number); - idstr[i] = xstrdup(idbuf); - int l = (int)strlen(idbuf); - if (l > idw) - idw = l; - free(ser); - } - for (size_t i = 0; i < n; i++) { - char path[64], line[512]; - snprintf(path, sizeof path, "result.items.%zu.date", i); - char *date = jstr_dup(resp, path); - snprintf(path, sizeof path, "result.items.%zu.description", i); - char *desc = jstr_dup(resp, path); - snprintf(path, sizeof path, "result.items.%zu.attachment_count", - i); - int atts = (int)jint_val(resp, path, 0); - snprintf(line, sizeof line, " %c %-*s %s %s", - atts ? 'x' : ' ', idw, idstr[i], date ? date : "", - desc ? desc : ""); - items[i] = xstrdup(line); - free(date); - free(desc); - free(idstr[i]); - } - free(idstr); - free(resp); - items[n] = xstrdup("+ Nytt verifikat (^N)"); + fetch = 0; + resort = 1; + } + if (resort) { + vlist_sort(v, n, a->voucher_sort); + vlist_lines_free(items, items ? n : 0); + items = vlist_lines(v, n); + resort = 0; } - size_t total = n + 1; int start = 0; if (a->voucher_sel) { - for (size_t i = 0; i < n; i++) - if (ids[i] == a->voucher_sel) { - start = (int)i; - break; - } + int i = vlist_index(v, n, a->voucher_sel); + if (i >= 0) + start = i; } + char title[96]; + snprintf(title, sizeof title, "Verifikat (sorterade på %s, s = byt)", + vlist_sort_label(a->voucher_sort)); + struct vlctx lctx = { a, 0 }; int cur = start; - int sel = tui_select_list("Verifikat", items, (int)total, start, 1, &cur, - 1, NULL, 0); + tui_list_hint_extra("s = sortera"); + int sel = tui_select_list_hook(title, items, (int)n + 1, start, 1, + &cur, 1, NULL, 0, vlist_key, &lctx); + tui_list_hint_extra(NULL); if (cur >= 0 && (size_t)cur < n) - a->voucher_sel = ids[cur]; + a->voucher_sel = v[cur].id; if (sel == -2) { - fetch = 1; + if (lctx.resort) + resort = 1; + else + fetch = 1; continue; } int64_t open_id = 0; @@ -982,14 +1116,10 @@ void vouchers_screen(struct app *a) if (sel >= 0) { if ((size_t)sel == n) want_new = 1; - else if ((size_t)sel < n) - open_id = ids[sel]; + else + open_id = v[sel].id; } else if (sel != -4) { - for (size_t i = 0; i < n; i++) - free(items[i]); - free(items); - free(ids); - return; + break; } if (want_new) { open_id = vouchers_new(a); @@ -1000,7 +1130,18 @@ void vouchers_screen(struct app *a) } for (;;) { int64_t nv = 0; - int act = voucher_detail(a, open_id, &nv); + int nav = 0; + /* a voucher posted since the fetch is not in v: no stepping */ + int pos = fetch ? -1 : vlist_index(v, n, open_id); + int act = voucher_detail(a, open_id, &nv, pos, (int)n, &nav); + if (act == 3) { + int next = vlist_step(n, pos, nav); + if (next < 0) + continue; + open_id = v[next].id; + a->voucher_sel = open_id; + continue; + } if (act == 2) { open_id = nv; a->voucher_sel = nv; @@ -1012,4 +1153,6 @@ void vouchers_screen(struct app *a) break; } } + vlist_lines_free(items, n); + vlist_free(v, n); } diff --git a/clients/tui.c b/clients/tui.c index 211df40..aa859e2 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -1410,15 +1410,16 @@ int tui_pager_hook(const char *title, const char *text, pager_line(2 + row, NULL); } char hint[512]; + const char *scroll = (flags & TUI_PAGER_NO_ARROWS) + ? "PgUp/PgDn/Home/End rullar" + : "piltangenter/PgUp/PgDn rullar"; if (extra_hint && *extra_hint) snprintf(hint, sizeof hint, - "piltangenter/PgUp/PgDn rullar F5 = uppdatera %s " - "q = tillbaka", + "%s F5 = uppdatera %s q = tillbaka", scroll, extra_hint); else - snprintf(hint, sizeof hint, - "piltangenter/PgUp/PgDn rullar F5 = uppdatera " - "q = tillbaka"); + snprintf(hint, sizeof hint, "%s F5 = uppdatera q = tillbaka", + scroll); tui_hints(hint); refresh(); int ch = in_key(); @@ -1443,6 +1444,9 @@ int tui_pager_hook(const char *title, const char *text, if (h == TUI_HOOK_STAY) continue; } + if ((flags & TUI_PAGER_NO_ARROWS) && + (ch == KEY_UP || ch == KEY_DOWN)) + continue; if (ch == KEY_DOWN && top + vbody < nb) top++; else if (ch == KEY_UP && top > 0) diff --git a/clients/tui.h b/clients/tui.h index 5ede574..c4fe116 100644 --- a/clients/tui.h +++ b/clients/tui.h @@ -162,6 +162,9 @@ 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) */ +/* Up/Down do not scroll (the key hook owns them, e.g. previous/next + record); PgUp/PgDn/Home/End still scroll and the hint says so. */ +#define TUI_PAGER_NO_ARROWS 0x2 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, diff --git a/clients/ui.h b/clients/ui.h index c546c91..8574121 100644 --- a/clients/ui.h +++ b/clients/ui.h @@ -37,6 +37,7 @@ struct app { char role[32]; int64_t fy; int64_t voucher_sel; /* last selected voucher id in the list view */ + int voucher_sort; /* VSORT_* for the list, kept in tui.conf */ int64_t invoice_sel; /* last selected invoice id in the list view */ int64_t customer_sel; /* last selected customer id in the list view */ int64_t employee_sel; /* last selected employee id in the list view */ diff --git a/clients/vlist.c b/clients/vlist.c new file mode 100644 index 0000000..933b704 --- /dev/null +++ b/clients/vlist.c @@ -0,0 +1,84 @@ +#include <stdlib.h> +#include <string.h> + +#include "vlist.h" + +static const char *const SORT_NAMES[VSORT_COUNT] = { + "number", "number-desc", "date", "date-desc", +}; + +static const char *const SORT_LABELS[VSORT_COUNT] = { + "nummer, stigande", "nummer, fallande", "datum, stigande", + "datum, fallande", +}; + +static int cmp_i64(int64_t a, int64_t b) +{ + return a < b ? -1 : a > b; +} + +static int cmp_number(const struct vlist_item *a, const struct vlist_item *b) +{ + int c = strcmp(a->series, b->series); + if (c) + return c; + return cmp_i64(a->number, b->number); +} + +static int g_mode; /* qsort has no context argument in C11 */ + +static int cmp_items(const void *pa, const void *pb) +{ + const struct vlist_item *a = pa, *b = pb; + int desc = g_mode == VSORT_NUMBER_DESC || g_mode == VSORT_DATE_DESC; + int c = 0; + if (g_mode == VSORT_DATE || g_mode == VSORT_DATE_DESC) + c = strcmp(a->date, b->date); + if (!c) + c = cmp_number(a, b); + if (!c) + c = cmp_i64(a->id, b->id); + return desc ? -c : c; +} + +void vlist_sort(struct vlist_item *v, size_t n, int mode) +{ + if (!v || n < 2) + return; + g_mode = mode >= 0 && mode < VSORT_COUNT ? mode : VSORT_NUMBER; + qsort(v, n, sizeof *v, cmp_items); +} + +const char *vlist_sort_label(int mode) +{ + return SORT_LABELS[mode >= 0 && mode < VSORT_COUNT ? mode : 0]; +} + +const char *vlist_sort_name(int mode) +{ + return SORT_NAMES[mode >= 0 && mode < VSORT_COUNT ? mode : 0]; +} + +int vlist_sort_parse(const char *name) +{ + for (int i = 0; name && i < VSORT_COUNT; i++) + if (strcmp(name, SORT_NAMES[i]) == 0) + return i; + return VSORT_NUMBER; +} + +int vlist_index(const struct vlist_item *v, size_t n, int64_t id) +{ + for (size_t i = 0; i < n; i++) + if (v[i].id == id) + return (int)i; + return -1; +} + +int vlist_step(size_t n, int idx, int dir) +{ + if (idx < 0 || (size_t)idx >= n) + return -1; + int next = idx + (dir < 0 ? -1 : 1); + return next >= 0 && (size_t)next < n ? next : -1; +} diff --git a/clients/vlist.h b/clients/vlist.h new file mode 100644 index 0000000..8324ade --- /dev/null +++ b/clients/vlist.h @@ -0,0 +1,46 @@ +#ifndef BOKF_VLIST_H +#define BOKF_VLIST_H + +#include <stddef.h> +#include <stdint.h> + +/* Voucher list ordering and stepping for the Verifikat view. Pure, so it is + unit-tested in tests/test_tui.c. */ + +struct vlist_item { + int64_t id; + char series[16]; + int64_t number; + char date[16]; + char *desc; /* owned by the caller */ + int attachments; +}; + +enum { + VSORT_NUMBER = 0, /* series, then number, ascending */ + VSORT_NUMBER_DESC, + VSORT_DATE, /* date, then series and number, ascending */ + VSORT_DATE_DESC, + VSORT_COUNT, +}; + +/* Sorts in place; ties fall back to the id so the order is stable. An + out-of-range mode sorts by number. */ +void vlist_sort(struct vlist_item *v, size_t n, int mode); + +/* Swedish label for the list title, e.g. "datum, fallande". */ +const char *vlist_sort_label(int mode); + +/* Config value ("number", "number-desc", "date", "date-desc") and back; + an unknown name gives VSORT_NUMBER. */ +const char *vlist_sort_name(int mode); +int vlist_sort_parse(const char *name); + +/* Index of the item with this id, or -1. */ +int vlist_index(const struct vlist_item *v, size_t n, int64_t id); + +/* Index dir steps from idx, or -1 past either end or when idx is not in + the list. */ +int vlist_step(size_t n, int idx, int dir); + +#endif diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 3ddb03b..2bd889f 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -938,7 +938,10 @@ commands. Implemented screens (0.1.0-dev): - **Dashboard** — status line with org, fiscal year, role and user. - **Verifikat** — list and detail view (rows with column headers, an underlag section separated by a rule, hash, link to corrected voucher); `c` - posts an ändringsverifikat. + posts an ändringsverifikat. The list holds the whole fiscal year (paged + `voucher.list` calls) and `s` cycles its sort order (number or date, + ascending or descending, remembered in `tui.conf`); in the detail view + up/down step to the previous/next voucher in that order. - **Nytt verifikat** — row editor with live balance display, F5 dry-run validation and F9 posting; one `client_ref` per form makes retries safe. F4 applies a konteringsmall (prompts for template and `x`). diff --git a/docs/STATE.md b/docs/STATE.md index fa0db74..d77cd78 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -14,6 +14,13 @@ unit tests and the docs consistency check. ## Resume here (2026-09-22) +- **Verifikat list/detail (2026-09-23, branch `eff/voucher-nav`)**: the + detail's columns are padded by display width (the header's `Benämning` + used to shift a column), zero amounts are blank, row texts and a `Summa` + line are shown; up/down step to the previous/next voucher in the list + order; `s` cycles the list sort (nummer/datum, stigande/fallande, saved in + `tui.conf`). The list now fetches every page of `voucher.list` — it + stopped at 200 vouchers before. Sort/step logic in `clients/vlist.[ch]`. - **TUI field editing (2026-09-23, branch `eff/tui-fields`, not merged)**: one shared field editor in `clients/tui.c` for forms, row-table header fields and prompts. Forms edit in place in the value column (no bottom diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md index c791f25..4895c9f 100644 --- a/docs/TUI-GUIDELINES.md +++ b/docs/TUI-GUIDELINES.md @@ -147,6 +147,8 @@ there. | `a` | Add/upload (Underlag) | | `c` | Correct (voucher detail) | | `d` | Delete/arkivera the selected row (only where the action exists; asks for confirmation) | +| `s` | Verifikat list: cycle the sort order — nummer stigande/fallande, datum stigande/fallande (ties by series and number). The title shows the current order; it is saved as `voucher_sort` in `tui.conf` | +| up/down (voucher detail) | Previous/next voucher in the list's current order; the title shows `(n av N)`. The detail scrolls with PgUp/PgDn/Home/End (`TUI_PAGER_NO_ARROWS`). A voucher posted from the detail (`^N`) is shown without stepping until the list is reopened | | `f` | Voucher detail: list the voucher's underlag — Enter opens Granska (text in a pager, PDFs/images in the desktop viewer) or Ladda ned…, `d` removes the link (asks first). Underlag: Enter does the same | | `u` / `b` | Faktura detail: `u` duplicates the invoice into a new draft (same rows, dates reset to today), `b` (unpaid invoices) prefills and posts the payment voucher, then marks the invoice paid | | `Ctrl+F` | Attach a file via the file browser (voucher form and voucher detail) | @@ -367,7 +369,9 @@ only place that touches ncurses. Rules: static storage. `TUI_NAV_NONE`, `TUI_FORM_BACK/REFRESH/SUBMIT` and the `TUI_HOOK_*` codes are all distinct. - `tui_pager_hook` takes `TUI_PAGER_SAVE` to enable the `s` save action; the - `extra_hint` is shown in the footer. Plain `tui_pager` keeps `s` on when a + `extra_hint` is shown in the footer. `TUI_PAGER_NO_ARROWS` leaves Up/Down + to the key hook (record stepping) and the footer says + `PgUp/PgDn/Home/End rullar` instead. Plain `tui_pager` keeps `s` on when a save hint is passed. Pager lines go through `tui_markup` (see below). - Styles come from `tui_style`/`tui_style_attrs`; report markup and menu sections from `tui_markup`/`TUI_MARK_HEADING`. Their fallbacks and the diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py index 9d6e9ca..864210c 100755 --- a/scripts/tui-golden.py +++ b/scripts/tui-golden.py @@ -543,6 +543,52 @@ SCENARIOS = [ ], }, { + # Up/Down in the voucher detail step through the list order; 's' + # cycles the list sort (ends back on the default, it is saved). + "name": "voucher-nav-sort", + "screen": "vouchers", + "expect": ["sorterade på nummer, stigande", "A1", "F1"], + "steps": [ + { + "keys": ["home", "enter"], + "expect": ["Verifikat A1 (1 av ", "Golden verifikat", + "Summa", "upp/ned = föregående/nästa"], + }, + { + "keys": ["down"], + "expect": [" (2 av "], + }, + { + "keys": ["up"], + "expect": ["Verifikat A1 (1 av "], + }, + { + "keys": ["esc", "s"], + "expect": ["sorterade på nummer, fallande"], + }, + { + "keys": ["home", "enter"], + "expect": [" (1 av "], + }, + { + "keys": ["down"], + "expect": [" (2 av "], + }, + { + "keys": ["esc", "s"], + "expect": ["sorterade på datum, stigande"], + }, + { + "keys": ["s"], + "expect": ["sorterade på datum, fallande"], + }, + { + "keys": ["s"], + "expect": ["sorterade på nummer, stigande"], + }, + ], + }, + { # Backspace in a freshly opened field edits the value; the next # key must not replace everything. "name": "form-edit-backspace", diff --git a/tests/test_tui.c b/tests/test_tui.c index b48544e..7ab969b 100644 --- a/tests/test_tui.c +++ b/tests/test_tui.c @@ -9,6 +9,7 @@ #include "drafts.h" #include "tui.h" +#include "vlist.h" #include "util.h" static int failures = 0; @@ -929,6 +930,60 @@ static void test_drafts(void) free(b); } +static void vl_set(struct vlist_item *it, int64_t id, const char *ser, + int64_t num, const char *date) +{ + memset(it, 0, sizeof *it); + it->id = id; + snprintf(it->series, sizeof it->series, "%s", ser); + it->number = num; + snprintf(it->date, sizeof it->date, "%s", date); +} + +static void test_vlist(void) +{ + struct vlist_item v[5]; + /* registered out of date order, two series, B10 after B9 */ + vl_set(&v[0], 1, "A", 1, "2026-03-01"); + vl_set(&v[1], 2, "B", 10, "2026-01-15"); + vl_set(&v[2], 3, "A", 2, "2026-01-15"); + vl_set(&v[3], 4, "B", 9, "2026-02-01"); + vl_set(&v[4], 5, "A", 3, "2026-01-15"); + + vlist_sort(v, 5, VSORT_NUMBER); + CHECK(v[0].id == 1 && v[1].id == 3 && v[2].id == 5 && v[3].id == 4 && + v[4].id == 2); /* A1 A2 A3 B9 B10: numeric, not text order */ + vlist_sort(v, 5, VSORT_NUMBER_DESC); + CHECK(v[0].id == 2 && v[4].id == 1); + /* same date: series and number decide */ + vlist_sort(v, 5, VSORT_DATE); + CHECK(v[0].id == 3 && v[1].id == 5 && v[2].id == 2 && v[3].id == 4 && + v[4].id == 1); + vlist_sort(v, 5, VSORT_DATE_DESC); + CHECK(v[0].id == 1 && v[1].id == 4 && v[2].id == 2 && v[3].id == 5 && + v[4].id == 3); + vlist_sort(v, 5, 99); /* unknown mode: by number */ + CHECK(v[0].id == 1 && v[4].id == 2); + vlist_sort(NULL, 0, VSORT_DATE); + + CHECK(vlist_index(v, 5, 4) == 3); + CHECK(vlist_index(v, 5, 42) == -1); + /* stepping stops at both ends and needs a position in the list */ + CHECK(vlist_step(5, 0, 1) == 1); + CHECK(vlist_step(5, 0, -1) == -1); + CHECK(vlist_step(5, 4, 1) == -1); + CHECK(vlist_step(5, 4, -1) == 3); + CHECK(vlist_step(5, -1, 1) == -1); + CHECK(vlist_step(0, 0, 1) == -1); + + /* config names round-trip; unknown falls back to number */ + for (int m = 0; m < VSORT_COUNT; m++) + CHECK(vlist_sort_parse(vlist_sort_name(m)) == m); + CHECK(vlist_sort_parse("bogus") == VSORT_NUMBER); + CHECK(vlist_sort_parse(NULL) == VSORT_NUMBER); + CHECK(strcmp(vlist_sort_label(VSORT_DATE_DESC), "datum, fallande") == 0); +} + int main(void) { test_disp_width(); @@ -955,6 +1010,7 @@ int main(void) test_rt_footer(); test_actions(); test_drafts(); + test_vlist(); printf("test_tui: %d checks, %d failures\n", checks, failures); return failures ? 1 : 0; } |
