summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile7
-rw-r--r--clients/bokftui.c5
-rw-r--r--clients/screens_vouchers.c351
-rw-r--r--clients/tui.c14
-rw-r--r--clients/tui.h3
-rw-r--r--clients/ui.h1
-rw-r--r--clients/vlist.c84
-rw-r--r--clients/vlist.h46
-rw-r--r--docs/PROTOCOL.md5
-rw-r--r--docs/STATE.md7
-rw-r--r--docs/TUI-GUIDELINES.md6
-rwxr-xr-xscripts/tui-golden.py46
-rw-r--r--tests/test_tui.c56
13 files changed, 517 insertions, 114 deletions
diff --git a/Makefile b/Makefile
index d27a270..d797efe 100644
--- a/Makefile
+++ b/Makefile
@@ -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;
}