diff options
Diffstat (limited to 'clients')
| -rw-r--r-- | clients/bokfctl.c | 26 | ||||
| -rw-r--r-- | clients/drafts.c | 249 | ||||
| -rw-r--r-- | clients/drafts.h | 49 | ||||
| -rw-r--r-- | clients/screens_attachments.c | 2 | ||||
| -rw-r--r-- | clients/screens_bokslut.c | 2 | ||||
| -rw-r--r-- | clients/screens_ib.c | 4 | ||||
| -rw-r--r-- | clients/screens_invoices.c | 799 | ||||
| -rw-r--r-- | clients/screens_payroll.c | 6 | ||||
| -rw-r--r-- | clients/screens_settings.c | 90 | ||||
| -rw-r--r-- | clients/screens_templates.c | 2 | ||||
| -rw-r--r-- | clients/screens_vouchers.c | 43 | ||||
| -rw-r--r-- | clients/tui.c | 193 | ||||
| -rw-r--r-- | clients/tui.h | 23 | ||||
| -rw-r--r-- | clients/ui.c | 105 | ||||
| -rw-r--r-- | clients/ui.h | 8 |
15 files changed, 1388 insertions, 213 deletions
diff --git a/clients/bokfctl.c b/clients/bokfctl.c index b5ecf47..47e42ac 100644 --- a/clients/bokfctl.c +++ b/clients/bokfctl.c @@ -19,6 +19,7 @@ static void usage(void) " --password PW login password (env BOKFD_PASSWORD)\n" " --token TOKEN API token instead of user/password (env BOKFD_TOKEN)\n" " --org ID active org for this request\n" + " --dry-run validate a mutating command without writing\n" " --version\n" "\n" "examples:\n" @@ -44,6 +45,7 @@ int main(int argc, char **argv) int64_t org = 0; const char *cmd = NULL; const char *args_json = NULL; + int dry_run = 0; for (int i = 1; i < argc; i++) { const char *a = argv[i]; @@ -88,6 +90,8 @@ int main(int argc, char **argv) return 2; } org = strtoll(val, NULL, 10); + } else if (strcmp(a, "--dry-run") == 0) { + dry_run = 1; } else if (strcmp(a, "--version") == 0) { printf("bokfctl %s\n", BOKF_VERSION); return 0; @@ -160,6 +164,28 @@ int main(int argc, char **argv) } } free(session); + if (dry_run) { + yyjson_mut_doc *md = yyjson_mut_doc_new(NULL); + yyjson_doc *qd = yyjson_read(req, strlen(req), 0); + yyjson_mut_val *root = qd ? yyjson_val_mut_copy(md, yyjson_doc_get_root(qd)) + : NULL; + char *out = NULL; + if (root && yyjson_mut_is_obj(root)) { + yyjson_mut_obj_remove_key(root, "dry_run"); + yyjson_mut_obj_add_bool(md, root, "dry_run", true); + yyjson_mut_doc_set_root(md, root); + out = yyjson_mut_write(md, 0, NULL); + } + yyjson_doc_free(qd); + yyjson_mut_doc_free(md); + free(req); + if (!out) { + fprintf(stderr, "bokfctl: request must be a JSON object\n"); + client_close(&conn); + return 2; + } + req = out; + } if (client_send_line(&conn, req) != 0) { fprintf(stderr, "bokfctl: send failed: %s\n", client_last_error()); free(req); diff --git a/clients/drafts.c b/clients/drafts.c new file mode 100644 index 0000000..f71e85c --- /dev/null +++ b/clients/drafts.c @@ -0,0 +1,249 @@ +#include "drafts.h" + +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <time.h> +#include <unistd.h> + +#include "util.h" +#include "yyjson.h" + +static void mkdirs_for(const char *path) +{ + char tmp[640]; + + if (snprintf(tmp, sizeof tmp, "%s", path) >= (int)sizeof tmp) + return; + for (char *p = tmp + 1; *p; p++) { + if (*p != '/') + continue; + *p = '\0'; + mkdir(tmp, 0700); + *p = '/'; + } +} + +static void cache_path(char *buf, size_t n) +{ + const char *cache = getenv("XDG_CACHE_HOME"); + + if (cache && *cache) { + snprintf(buf, n, "%s/bokf/drafts.json", cache); + return; + } + const char *home = getenv("HOME"); + snprintf(buf, n, "%s/.cache/bokf/drafts.json", + home && *home ? home : "."); +} + +static void rec_clear(struct draft *r) +{ + free(r->entity); + free(r->id); + free(r->fields); +} + +void drafts_free(struct drafts *d) +{ + if (!d) + return; + for (size_t i = 0; i < d->n; i++) + rec_clear(&d->v[i]); + free(d->v); + d->v = NULL; + d->n = d->cap = 0; +} + +static struct draft *rec_find(const struct drafts *d, int64_t org, + const char *entity, const char *id) +{ + for (size_t i = 0; i < d->n; i++) { + struct draft *r = &d->v[i]; + + if (r->org == org && strcmp(r->entity, entity) == 0 && + strcmp(r->id, id) == 0) + return r; + } + return NULL; +} + +static void rec_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields) +{ + struct draft *r = rec_find(d, org, entity, id); + + if (!r) { + if (d->n == d->cap) { + d->cap = d->cap ? d->cap * 2 : 8; + d->v = xrealloc(d->v, d->cap * sizeof *d->v); + } + r = &d->v[d->n++]; + memset(r, 0, sizeof *r); + r->org = org; + r->entity = xstrdup(entity); + r->id = xstrdup(id); + r->fields = xstrdup(fields && *fields ? fields : "{}"); + return; + } + free(r->fields); + r->fields = xstrdup(fields && *fields ? fields : "{}"); +} + +static void drafts_write(const struct drafts *d) +{ + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_int(doc, root, "version", 1); + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (size_t i = 0; i < d->n; i++) { + const struct draft *r = &d->v[i]; + yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr); + yyjson_mut_obj_add_int(doc, o, "org", r->org); + yyjson_mut_obj_add_strcpy(doc, o, "entity", r->entity); + yyjson_mut_obj_add_strcpy(doc, o, "id", r->id); + yyjson_mut_val *f = yyjson_mut_rawcpy(doc, r->fields); + yyjson_mut_obj_add_val(doc, o, "fields", f ? f : yyjson_mut_obj(doc)); + } + yyjson_mut_obj_add_val(doc, root, "drafts", arr); + char *json = yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, NULL); + yyjson_mut_doc_free(doc); + if (!json) + return; + + mkdirs_for(d->path); + char tmp[640]; + if (snprintf(tmp, sizeof tmp, "%s.tmp", d->path) >= (int)sizeof tmp) { + free(json); + return; + } + int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) { + FILE *f = fdopen(fd, "w"); + if (f) { + fputs(json, f); + fclose(f); + rename(tmp, d->path); + } else { + close(fd); + } + } + free(json); +} + +static void drafts_load(struct drafts *d) +{ + FILE *f = fopen(d->path, "rb"); + if (!f) + return; + struct buf b; + char tmp[8192]; + size_t k; + buf_init(&b); + while ((k = fread(tmp, 1, sizeof tmp, f)) > 0) + buf_append(&b, tmp, k); + fclose(f); + yyjson_doc *doc = yyjson_read((const char *)b.p, b.len, 0); + buf_free(&b); + if (!doc) + return; + yyjson_val *arr = yyjson_obj_get(yyjson_doc_get_root(doc), "drafts"); + if (yyjson_is_arr(arr)) { + size_t idx, max; + yyjson_val *it; + yyjson_arr_foreach(arr, idx, max, it) { + yyjson_val *org = yyjson_obj_get(it, "org"); + yyjson_val *entity = yyjson_obj_get(it, "entity"); + yyjson_val *id = yyjson_obj_get(it, "id"); + yyjson_val *fields = yyjson_obj_get(it, "fields"); + if (!yyjson_is_int(org) || !yyjson_is_str(entity) || + !yyjson_is_str(id) || !yyjson_is_obj(fields)) + continue; + char *ft = yyjson_val_write(fields, 0, NULL); + rec_put(d, yyjson_get_sint(org), yyjson_get_str(entity), + yyjson_get_str(id), ft ? ft : "{}"); + free(ft); + } + } + yyjson_doc_free(doc); +} + +void drafts_init_path(struct drafts *d, const char *path) +{ + memset(d, 0, sizeof *d); + snprintf(d->path, sizeof d->path, "%s", path); + drafts_load(d); +} + +void drafts_init(struct drafts *d) +{ + char path[600]; + cache_path(path, sizeof path); + drafts_init_path(d, path); +} + +const char *drafts_get(const struct drafts *d, int64_t org, + const char *entity, const char *id) +{ + const struct draft *r = rec_find(d, org, entity, id); + return r ? r->fields : NULL; +} + +int drafts_have(const struct drafts *d, int64_t org, const char *entity, + const char *id) +{ + return rec_find(d, org, entity, id) != NULL; +} + +void drafts_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields) +{ + rec_put(d, org, entity, id, fields); + drafts_write(d); +} + +void drafts_del(struct drafts *d, int64_t org, const char *entity, + const char *id) +{ + struct draft *r = rec_find(d, org, entity, id); + + if (!r) + return; + size_t i = (size_t)(r - d->v); + rec_clear(r); + memmove(&d->v[i], &d->v[i + 1], (d->n - i - 1) * sizeof *d->v); + d->n--; + drafts_write(d); +} + +size_t drafts_count(const struct drafts *d, int64_t org, const char *entity) +{ + size_t n = 0; + + for (size_t i = 0; i < d->n; i++) + if (d->v[i].org == org && strcmp(d->v[i].entity, entity) == 0) + n++; + return n; +} + +void drafts_foreach(const struct drafts *d, int64_t org, const char *entity, + void (*cb)(void *ud, const char *id, const char *fields), + void *ud) +{ + for (size_t i = 0; i < d->n; i++) + if (d->v[i].org == org && strcmp(d->v[i].entity, entity) == 0) + cb(ud, d->v[i].id, d->v[i].fields); +} + +char *drafts_new_id(void) +{ + static unsigned seq; + char buf[64]; + + snprintf(buf, sizeof buf, "ny-%lld-%u", (long long)time(NULL), ++seq); + return xstrdup(buf); +} diff --git a/clients/drafts.h b/clients/drafts.h new file mode 100644 index 0000000..2cc67db --- /dev/null +++ b/clients/drafts.h @@ -0,0 +1,49 @@ +#ifndef BOKF_DRAFTS_H +#define BOKF_DRAFTS_H + +#include <stddef.h> +#include <stdint.h> + +/* Client-local edit drafts: every non-committed edit is mirrored to + $XDG_CACHE_HOME/bokf/drafts.json (mode 0600, atomic replace) so a reload, + a crash or Ctrl+C never loses work. A record is keyed by + (org, entity, id); a new entity uses a temporary id from + drafts_new_id(). See docs/TUI-GUIDELINES.md "Interaction model". */ + +struct draft { + int64_t org; + char *entity; + char *id; + char *fields; /* JSON object text */ +}; + +struct drafts { + struct draft *v; + size_t n, cap; + char path[600]; +}; + +void drafts_init(struct drafts *d); +/* Explicit path, for tests. */ +void drafts_init_path(struct drafts *d, const char *path); +void drafts_free(struct drafts *d); + +/* The draft's fields JSON, or NULL; the store owns the string. */ +const char *drafts_get(const struct drafts *d, int64_t org, + const char *entity, const char *id); +int drafts_have(const struct drafts *d, int64_t org, const char *entity, + const char *id); +/* Inserts or replaces the draft and persists the file. */ +void drafts_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields); +void drafts_del(struct drafts *d, int64_t org, const char *entity, + const char *id); +size_t drafts_count(const struct drafts *d, int64_t org, const char *entity); +void drafts_foreach(const struct drafts *d, int64_t org, const char *entity, + void (*cb)(void *ud, const char *id, const char *fields), + void *ud); + +/* "ny-<time>-<seq>", unique within the process. Caller frees. */ +char *drafts_new_id(void); + +#endif diff --git a/clients/screens_attachments.c b/clients/screens_attachments.c index 3eeb9e9..c72ee67 100644 --- a/clients/screens_attachments.c +++ b/clients/screens_attachments.c @@ -158,6 +158,6 @@ void inbox_screen(struct app *a) continue; } if (sel_id) - attachment_download(a, sel_id); + attachment_view_or_download(a, sel_id); } } diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c index 3f69982..96c6fe9 100644 --- a/clients/screens_bokslut.c +++ b/clients/screens_bokslut.c @@ -242,7 +242,7 @@ void bokslut_screen(struct app *a) ff[7].cap = sizeof rate; ff[7].kind = TUI_F_TEXT; tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför F5 = visa" - " bokslutsplan ^Enter/F9 = bokför planen (frågar" + " bokslutsplan F9 = bokför planen (frågar" " först) Esc/q = tillbaka ^C = avsluta"); int r = tui_form_run_actions("Bokslut", ff, nf + 2, can_edit, acts, 5, NULL, &sel); diff --git a/clients/screens_ib.c b/clients/screens_ib.c index 8950667..2d62297 100644 --- a/clients/screens_ib.c +++ b/clients/screens_ib.c @@ -167,7 +167,7 @@ static int ib_post_delta(struct ibform *ib) 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"); + yyjson_mut_obj_add_strcpy(d, o, "series", a->ib_series); char *ref = util_random_id("tui-", 8); yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref); free(ref); @@ -272,7 +272,7 @@ static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold) for (;;) { int rr = tui_rt_run("Ingående balans", &ib.rt, "Tab = byta fält F5 = validera ^X = rensa rad" - " ^Enter/F9 = spara Esc = avbryt"); + " F9 = spara Esc = avbryt"); if (rr == -1) return 0; char msg[256]; diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c index ca59eef..712a8a8 100644 --- a/clients/screens_invoices.c +++ b/clients/screens_invoices.c @@ -14,6 +14,7 @@ #include <unistd.h> #include "client.h" +#include "drafts.h" #include "tui.h" #include "formula.h" #include "util.h" @@ -219,12 +220,128 @@ static int customer_save(struct app *a, int64_t id, const char *name, return ok; } -static void customer_form(struct app *a, int64_t id) +/* Drafts for the customer register: new entities exist only as drafts + until Spara writes them (docs/TUI-GUIDELINES.md "Interaction model"). */ + +static struct drafts g_drafts; +static int g_drafts_ready; + +static struct drafts *kdrafts(void) +{ + if (!g_drafts_ready) { + drafts_init(&g_drafts); + g_drafts_ready = 1; + } + return &g_drafts; +} + +struct cform_ctx { + struct app *a; + int64_t id; + const char *draft_id; + int deleted; + char name[256]; + char address[512]; + char postal[32]; + char city[128]; + char vat[64]; + char email[256]; + char your_ref[128]; + char payment[24]; + char notes[2048]; +}; + +static char *customer_fields_json(const struct cform_ctx *c) +{ + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_strcpy(d, o, "name", c->name); + yyjson_mut_obj_add_strcpy(d, o, "address", c->address); + yyjson_mut_obj_add_strcpy(d, o, "postal_code", c->postal); + yyjson_mut_obj_add_strcpy(d, o, "city", c->city); + yyjson_mut_obj_add_strcpy(d, o, "vat_nr", c->vat); + yyjson_mut_obj_add_strcpy(d, o, "email", c->email); + yyjson_mut_obj_add_strcpy(d, o, "your_ref", c->your_ref); + yyjson_mut_obj_add_int(d, o, "payment_days", + strtoll(c->payment, NULL, 10)); + yyjson_mut_obj_add_strcpy(d, o, "notes", c->notes); + char *s = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + return s; +} + +static void customer_load_json(const char *json, struct cform_ctx *c) +{ + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + + if (!doc) + return; + yyjson_val *o = yyjson_doc_get_root(doc); + const struct { + const char *key; + char *dst; + size_t cap; + } fields[] = { + { "name", c->name, sizeof c->name }, + { "address", c->address, sizeof c->address }, + { "postal_code", c->postal, sizeof c->postal }, + { "city", c->city, sizeof c->city }, + { "vat_nr", c->vat, sizeof c->vat }, + { "email", c->email, sizeof c->email }, + { "your_ref", c->your_ref, sizeof c->your_ref }, + { "notes", c->notes, sizeof c->notes }, + }; + for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) + snprintf(fields[i].dst, fields[i].cap, "%s", + vstr(o, fields[i].key)); + int64_t days = vint(o, "payment_days"); + if (days > 0) + snprintf(c->payment, sizeof c->payment, "%lld", (long long)days); + yyjson_doc_free(doc); +} + +static int cform_key(void *ud, int ch) +{ + struct cform_ctx *c = ud; + + if (ch != KEY_F(2)) + return TUI_HOOK_NONE; + int have = drafts_have(kdrafts(), c->a->org, "customer", c->draft_id); + struct tui_action acts[] = { + { "customer.save", "Spara", 0, 1, NULL }, + { "customer.draft.delete", "Radera utkast", 0, have, "inget utkast" }, + }; + static int afocus; + int r = tui_action_menu("Åtgärder", acts, 2, &afocus); + if (r == 0) + return TUI_HOOK_SUBMIT; + if (r == 1) { + if (tui_confirm("Kund", "Radera utkastet?")) { + drafts_del(kdrafts(), c->a->org, "customer", c->draft_id); + c->deleted = 1; + return TUI_HOOK_BACK; + } + return TUI_HOOK_STAY; + } + return TUI_HOOK_STAY; +} + +static void customer_form(struct app *a, int64_t id, const char *draft_id) { - char name[256] = "", address[512] = "", postal[32] = "", city[128] = ""; - char vat[64] = "", email[256] = "", your_ref[128] = ""; - char payment[16] = "30", notes[2048] = ""; - if (id > 0) { + struct cform_ctx c; + + memset(&c, 0, sizeof c); + c.a = a; + c.id = id; + c.draft_id = draft_id; + snprintf(c.payment, sizeof c.payment, "30"); + + const char *fields = drafts_get(kdrafts(), a->org, "customer", draft_id); + if (fields) { + customer_load_json(fields, &c); + } else if (id > 0) { char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id); char *resp = @@ -234,43 +351,46 @@ static void customer_form(struct app *a, int64_t id) free(resp); return; } - struct { + const struct { const char *key; char *dst; size_t cap; - } fields[] = { - { "name", name, sizeof name }, - { "address", address, sizeof address }, - { "postal_code", postal, sizeof postal }, - { "city", city, sizeof city }, - { "vat_nr", vat, sizeof vat }, - { "email", email, sizeof email }, - { "your_ref", your_ref, sizeof your_ref }, - { "notes", notes, sizeof notes }, + } f[] = { + { "name", c.name, sizeof c.name }, + { "address", c.address, sizeof c.address }, + { "postal_code", c.postal, sizeof c.postal }, + { "city", c.city, sizeof c.city }, + { "vat_nr", c.vat, sizeof c.vat }, + { "email", c.email, sizeof c.email }, + { "your_ref", c.your_ref, sizeof c.your_ref }, + { "notes", c.notes, sizeof c.notes }, }; - for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) { - char path[64], *v; - snprintf(path, sizeof path, "result.%s", fields[i].key); + for (size_t i = 0; i < sizeof f / sizeof f[0]; i++) { + char path[64]; + char *v; + snprintf(path, sizeof path, "result.%s", f[i].key); v = jstr_dup(resp, path); if (v) { - snprintf(fields[i].dst, fields[i].cap, "%s", v); + snprintf(f[i].dst, f[i].cap, "%s", v); free(v); } } - snprintf(payment, sizeof payment, "%lld", + snprintf(c.payment, sizeof c.payment, "%lld", (long long)jint_val(resp, "result.payment_days", 30)); free(resp); } + const char *labels[] = { "Namn", "Adress", "Postnummer", "Ort", "Momsreg.nr", "E-post", "Er referens", "Betalningsdagar", "Anteckningar" }; - char *vals[] = { name, address, postal, city, vat, - email, your_ref, payment, notes }; - size_t caps[] = { sizeof name, sizeof address, sizeof postal, - sizeof city, sizeof vat, sizeof email, - sizeof your_ref, sizeof payment, sizeof notes }; + char *vals[] = { c.name, c.address, c.postal, c.city, + c.vat, c.email, c.your_ref, c.payment, + c.notes }; + size_t caps[] = { sizeof c.name, sizeof c.address, sizeof c.postal, + sizeof c.city, sizeof c.vat, sizeof c.email, + sizeof c.your_ref, sizeof c.payment, sizeof c.notes }; struct tui_form_field ff[10]; memset(ff, 0, sizeof ff); for (int i = 0; i < 9; i++) { @@ -282,22 +402,47 @@ static void customer_form(struct app *a, int64_t id) ff[9].label = ""; ff[9].value = "Spara"; ff[9].kind = TUI_F_ACTION; + static int sel = 0; + tui_form_hint_extra("F2 = åtgärder"); for (;;) { - int r = tui_form_run(id > 0 ? "Kund" : "Ny kund", ff, 10, 1, &sel); + char title[64]; + snprintf(title, sizeof title, "%s%s", id > 0 ? "Kund" : "Ny kund", + drafts_have(kdrafts(), a->org, "customer", draft_id) + ? " <UTKAST>" + : ""); + int r = tui_form_run_hook(title, ff, 10, 1, cform_key, &c, &sel); + if (c.deleted) + break; + if (r >= 0) { + char *json = customer_fields_json(&c); + if (json) { + drafts_put(kdrafts(), a->org, "customer", draft_id, json); + free(json); + } + } if (r == TUI_FORM_BACK) - return; + break; if (r == TUI_FORM_REFRESH) { - customer_save(a, id, name, address, postal, city, vat, email, - your_ref, payment, notes, 1); + customer_save(a, id, c.name, c.address, c.postal, c.city, c.vat, + c.email, c.your_ref, c.payment, c.notes, 1); continue; } if (r == TUI_FORM_SUBMIT || r == 9) { - if (customer_save(a, id, name, address, postal, city, vat, email, - your_ref, payment, notes, 0)) - return; + if (customer_save(a, id, c.name, c.address, c.postal, c.city, + c.vat, c.email, c.your_ref, c.payment, c.notes, + 0)) { + drafts_del(kdrafts(), a->org, "customer", draft_id); + break; + } + char *json = customer_fields_json(&c); + if (json) { + drafts_put(kdrafts(), a->org, "customer", draft_id, json); + free(json); + } } } + tui_form_hint_extra(NULL); } static void customer_archive_toggle(struct app *a, int64_t id, const char *name, @@ -319,59 +464,170 @@ static void customer_archive_toggle(struct app *a, int64_t id, const char *name, free(r); } +struct clist { + struct app *a; + int *cur; + char **items; + char **names; + char **dkeys; + int64_t *ids; + unsigned char *active; + size_t nserver; + size_t nrows; /* server rows + temp drafts; the add row is at nrows */ +}; + +static void clist_clear(struct clist *l) +{ + if (l->items) { + for (size_t i = 0; i <= l->nrows; i++) { + free(l->items[i]); + free(l->names[i]); + free(l->dkeys[i]); + } + } + free(l->items); + free(l->names); + free(l->dkeys); + free(l->ids); + free(l->active); + l->items = NULL; + l->names = NULL; + l->dkeys = NULL; + l->ids = NULL; + l->active = NULL; + l->nserver = l->nrows = 0; +} + +static int clist_is_server_id(const struct clist *l, const char *id) +{ + long long v = strtoll(id, NULL, 10); + + if (v <= 0) + return 0; + for (size_t i = 0; i < l->nserver; i++) + if (l->ids[i] == v) + return 1; + return 0; +} + +static void clist_collect_temp(void *ud, const char *id, const char *fields) +{ + struct clist *l = ud; + char nbuf[256], line[768]; + const char *name = "(namnlös)"; + yyjson_doc *doc; + + if (clist_is_server_id(l, id)) + return; + doc = yyjson_read(fields, strlen(fields), 0); + if (doc) { + yyjson_val *o = yyjson_doc_get_root(doc); + const char *n = vstr(o, "name"); + if (*n) + name = n; + size_t i = l->nrows; + l->names[i] = xstrdup(name); + snprintf(nbuf, sizeof nbuf, "<UTKAST> %s", name); + snprintf(line, sizeof line, "%s", nbuf); + l->items[i] = xstrdup(line); + l->dkeys[i] = xstrdup(id); + l->ids[i] = 0; + l->active[i] = 1; + l->nrows++; + yyjson_doc_free(doc); + return; + } + size_t i = l->nrows; + l->names[i] = xstrdup(name); + snprintf(line, sizeof line, "<UTKAST> %s", name); + l->items[i] = xstrdup(line); + l->dkeys[i] = xstrdup(id); + l->ids[i] = 0; + l->active[i] = 1; + l->nrows++; +} + +static int clist_key(void *ud, int ch) +{ + struct clist *l = ud; + static int afocus; + + if (ch != KEY_F(2) || !l->cur) + return TUI_HOOK_NONE; + int i = *l->cur; + if (i < 0 || (size_t)i >= l->nrows) + return TUI_HOOK_STAY; + struct tui_action acts[] = { + { "customer.open", "Öppna", 0, 1, NULL }, + { "customer.draft.delete", "Radera utkast", 0, l->dkeys[i] != NULL, + "inget utkast" }, + { "customer.archive", l->active[i] ? "Arkivera" : "Återaktivera", 0, + l->ids[i] != 0, "gäller inte utkast" }, + }; + int r = tui_action_menu("Åtgärder", acts, 3, &afocus); + if (r == 0) { + char idbuf[32]; + const char *dk = l->dkeys[i]; + if (!dk) { + snprintf(idbuf, sizeof idbuf, "%lld", (long long)l->ids[i]); + dk = idbuf; + } + customer_form(l->a, l->ids[i], dk); + return TUI_HOOK_REFRESH; + } + if (r == 1) { + if (tui_confirm("Kund", "Radera utkastet?")) + drafts_del(kdrafts(), l->a->org, "customer", l->dkeys[i]); + return TUI_HOOK_REFRESH; + } + if (r == 2) { + customer_archive_toggle(l->a, l->ids[i], l->names[i], + l->active[i] != 0); + return TUI_HOOK_REFRESH; + } + return TUI_HOOK_STAY; +} + void customers_screen(struct app *a) { - char **items = NULL; - char **names = NULL; - int64_t *ids = NULL; - unsigned char *active = NULL; - size_t n = 0; + struct clist l; + memset(&l, 0, sizeof l); + l.a = a; + int cur = 0; + l.cur = &cur; int fetch = 1; for (;;) { if (g_quit) { - for (size_t i = 0; i < n; i++) { - free(items[i]); - free(names[i]); - } - free(items); - free(names); - free(ids); - free(active); + clist_clear(&l); return; } if (fetch) { - for (size_t i = 0; i < n; i++) { - free(items[i]); - free(names[i]); - } - free(items); - free(names); - free(ids); - free(active); - items = NULL; - names = NULL; - ids = NULL; - active = NULL; - n = 0; + clist_clear(&l); fetch = 0; char *resp = client_rpc(&a->conn, "customer.list", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Kunder", resp); free(resp); + clist_clear(&l); return; } - n = jarr_size(resp, "result.items"); - items = xcalloc(n + 1, sizeof(char *)); - names = xcalloc(n + 1, sizeof(char *)); - ids = xcalloc(n ? n : 1, sizeof(int64_t)); - active = xcalloc(n + 1, 1); + size_t n = jarr_size(resp, "result.items"); + size_t ndraft = drafts_count(kdrafts(), a->org, "customer"); + size_t cap = n + ndraft + 2; + l.items = xcalloc(cap, sizeof(char *)); + l.names = xcalloc(cap, sizeof(char *)); + l.dkeys = xcalloc(cap, sizeof(char *)); + l.ids = xcalloc(cap, sizeof(int64_t)); + l.active = xcalloc(cap, 1); + l.nserver = n; + l.nrows = n; for (size_t i = 0; i < n; i++) { - char path[64], line[768]; + char path[64], line[768], idbuf[32]; snprintf(path, sizeof path, "result.items.%zu.id", i); - ids[i] = jint_val(resp, path, 0); + l.ids[i] = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.active", i); - active[i] = (unsigned char)jbool_val(resp, path, 1); + l.active[i] = (unsigned char)jbool_val(resp, path, 1); snprintf(path, sizeof path, "result.items.%zu.name", i); char *name = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.postal_code", i); @@ -390,60 +646,81 @@ void customers_screen(struct app *a) tui_pad_field(pbuf, sizeof pbuf, 30); snprintf(vbuf, sizeof vbuf, "%s", vat ? vat : ""); tui_pad_field(vbuf, sizeof vbuf, 18); - snprintf(line, sizeof line, "%s %s %s %3lld dagar %s", nbuf, - pbuf, vbuf, (long long)days, - active[i] ? "" : "(arkiverad)"); - items[i] = xstrdup(line); - names[i] = xstrdup(name ? name : ""); + snprintf(idbuf, sizeof idbuf, "%lld", (long long)l.ids[i]); + int isdraft = + drafts_have(kdrafts(), a->org, "customer", idbuf); + if (isdraft) + l.dkeys[i] = xstrdup(idbuf); + snprintf(line, sizeof line, "%s%s %s %s %3lld dagar %s", + isdraft ? "<UTKAST> " : "", nbuf, pbuf, vbuf, + (long long)days, + l.active[i] ? "" : "(arkiverad)"); + l.items[i] = xstrdup(line); + l.names[i] = xstrdup(name ? name : ""); free(name); free(postal); free(city); free(vat); } free(resp); - items[n] = xstrdup("+ Ny kund (^N)"); + drafts_foreach(kdrafts(), a->org, "customer", clist_collect_temp, + &l); + l.items[l.nrows] = xstrdup("+ Ny kund (^N)"); + l.names[l.nrows] = xstrdup(""); + l.ids[l.nrows] = 0; + l.active[l.nrows] = 1; } int start = 0; if (a->customer_sel) { - for (size_t i = 0; i < n; i++) - if (ids[i] == a->customer_sel) { + for (size_t i = 0; i < l.nserver; i++) + if (l.ids[i] == a->customer_sel) { start = (int)i; break; } } - int cur = start; - int sel = tui_select_list("Kunder", items, (int)n + 1, start, 1, &cur, - 1, "arkivera/återaktivera", 0); - if (cur >= 0 && (size_t)cur < n) - a->customer_sel = ids[cur]; + cur = start; + tui_list_hint_extra("F2 = åtgärder"); + int sel = tui_select_list_hook("Kunder", l.items, + (int)l.nrows + 1, start, 1, &cur, 1, + "arkivera/återaktivera", 0, clist_key, + &l); + tui_list_hint_extra(NULL); + if (cur >= 0 && (size_t)cur < l.nserver) + a->customer_sel = l.ids[cur]; if (sel == -1) { - for (size_t i = 0; i < n; i++) { - free(items[i]); - free(names[i]); - } - free(items); - free(names); - free(ids); - free(active); + clist_clear(&l); return; } if (sel == -2) { fetch = 1; continue; } - if (sel == -6 && cur >= 0 && (size_t)cur < n) { - customer_archive_toggle(a, ids[cur], names[cur], - active[cur] != 0); + if (sel == -6 && cur >= 0 && (size_t)cur < l.nrows) { + if (l.ids[cur] == 0) { + tui_message("Kund", "Utkast raderas med F2."); + } else { + customer_archive_toggle(a, l.ids[cur], l.names[cur], + l.active[cur] != 0); + } fetch = 1; continue; } - if (sel == -4 || (sel >= 0 && (size_t)sel == n)) { - customer_form(a, 0); + if (sel == -4 || (cur >= 0 && (size_t)cur == l.nrows)) { + char *tid = drafts_new_id(); + drafts_put(kdrafts(), a->org, "customer", tid, "{}"); + customer_form(a, 0, tid); + free(tid); fetch = 1; continue; } - if (sel >= 0 && (size_t)sel < n) { - customer_form(a, ids[sel]); + if (sel >= 0 && (size_t)sel < l.nrows) { + char idbuf[32]; + const char *dk = l.dkeys[sel]; + if (!dk) { + snprintf(idbuf, sizeof idbuf, "%lld", (long long)l.ids[sel]); + dk = idbuf; + } + customer_form(a, l.ids[sel], dk); fetch = 1; continue; } @@ -457,9 +734,13 @@ struct invctx { int64_t id; int64_t number; int64_t customer_id; + int64_t total_ore; char customer[256]; + char paid_date[16]; }; +static int64_t invoices_new_from(struct app *a, int64_t id); + static void inv_detail_pdf(struct invctx *ctx) { char args[64]; @@ -476,7 +757,7 @@ static void inv_detail_pdf(struct invctx *ctx) char fname[800]; snprintf(fname, sizeof fname, "Faktura %lld %s.pdf", (long long)ctx->number, ctx->customer); - pdf_save_and_open(b64, fname, "Faktura PDF"); + file_save_and_open(b64, fname, "Faktura PDF"); free(b64); } free(resp); @@ -523,6 +804,61 @@ static void inv_detail_send(struct invctx *ctx) free(r); } +/* Prefills the new-invoice form with the payment voucher: D the org's + bank account, K the invoice receivable account, both editable. */ +static void inv_detail_pay(struct invctx *ctx) +{ + struct app *a = ctx->a; + if (ctx->paid_date[0]) { + tui_message("Kvittera betalning", "Fakturan är redan betald."); + return; + } + char pay[16] = "1930", rec[16] = "1510"; + char *resp = client_rpc(&a->conn, "settings.get", a->session, a->org, + "{}"); + if (resp && client_ok(resp)) { + char *v = jstr_dup(resp, "result.bank_account"); + if (v && *v) + snprintf(pay, sizeof pay, "%s", v); + free(v); + v = jstr_dup(resp, "result.invoice_receivable_account"); + if (v && *v) + snprintf(rec, sizeof rec, "%s", v); + free(v); + } + free(resp); + char desc[160]; + snprintf(desc, sizeof desc, "Betalning faktura %lld %.120s", + (long long)ctx->number, ctx->customer); + struct voucher_prefill p; + memset(&p, 0, sizeof p); + char date[16]; + today_iso(date, sizeof date); + p.date = date; + p.description = desc; + p.bank_account = pay; + p.counter_account = rec; + p.amount_ore = ctx->total_ore; + int64_t vid = vouchers_new_prefill(a, &p); + if (vid <= 0) + return; + char args[96]; + snprintf(args, sizeof args, "{\"id\":%lld,\"voucher_id\":%lld}", + (long long)ctx->id, (long long)vid); + resp = client_rpc(&a->conn, "invoice.pay", a->session, a->org, args); + if (resp && client_ok(resp)) { + char *ser = jstr_dup(resp, "result.voucher_series"); + int64_t num = jint_val(resp, "result.voucher_number", 0); + tui_message("Kvittera betalning", "Faktura %lld kvitterad (%s%lld).", + (long long)ctx->number, ser ? ser : "", + (long long)num); + free(ser); + } else { + show_error("Kvittera betalning", resp); + } + free(resp); +} + static int inv_key(void *ud, int ch) { struct invctx *ctx = ud; @@ -534,6 +870,14 @@ static int inv_key(void *ud, int ch) inv_detail_send(ctx); return TUI_HOOK_STAY; } + if (ch == 'u') { + invoices_new_from(ctx->a, ctx->id); + return TUI_HOOK_REFRESH; + } + if (ch == 'b') { + inv_detail_pay(ctx); + return TUI_HOOK_REFRESH; + } return TUI_HOOK_NONE; } @@ -565,10 +909,19 @@ static void invoices_detail(struct app *a, int64_t id) char *ocr = jstr_dup(resp, "result.ocr"); char *sent_at = jstr_dup(resp, "result.last_sent_at"); char *sent_to = jstr_dup(resp, "result.last_sent_to"); + char *paid_date = jstr_dup(resp, "result.paid_date"); + char *paid_ser = jstr_dup(resp, "result.paid_voucher_series"); + int64_t paid_num = jint_val(resp, "result.paid_voucher_number", 0); + char statbuf[96]; + if (paid_date && *paid_date) + snprintf(statbuf, sizeof statbuf, "betald %s", paid_date); + else + snprintf(statbuf, sizeof statbuf, "%s", + invoice_status_sv(status)); struct buf t; buf_init(&t); buf_line(&t, MK_HEAD "Faktura %lld %s\n", (long long)number, - invoice_status_sv(status)); + statbuf); buf_line(&t, MK_DIM "%s\n\n", cust ? cust : ""); buf_line(&t, "Fakturadatum: %s\n", date ? date : ""); buf_line(&t, "Förfallodatum: %s\n", due ? due : ""); @@ -584,6 +937,9 @@ static void invoices_detail(struct app *a, int64_t id) buf_line(&t, "Skickad: %s%s%s\n", sent_at, sent_to && *sent_to ? " till " : "", sent_to && *sent_to ? sent_to : ""); + if (paid_ser && *paid_ser && paid_num > 0) + buf_line(&t, "Betalningsverifikat: %s%lld\n", paid_ser, + (long long)paid_num); if (notes && *notes) buf_line(&t, "\nFritext:\n%s\n", notes); buf_line(&t, "\n"); @@ -609,6 +965,18 @@ static void invoices_detail(struct app *a, int64_t id) char *note = jstr_dup(resp, path); snprintf(path, sizeof path, "result.rows.%zu.amount_ore", i); int64_t amount = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.rows.%zu.is_text", i); + if (jbool_val(resp, path, 0)) { + char dbuf[256]; + snprintf(dbuf, sizeof dbuf, "%s", desc ? desc : ""); + tui_pad_field(dbuf, sizeof dbuf, 34); + buf_line(&t, "%s\n", dbuf); + free(desc); + free(unit); + free(vat_code); + free(note); + continue; + } char dbuf[256], qbuf[24], qcol[24], ubuf[24], pcol[40], vbuf[24], nbuf[128], acol[40]; snprintf(dbuf, sizeof dbuf, "%s", desc ? desc : ""); @@ -644,10 +1012,16 @@ static void invoices_detail(struct app *a, int64_t id) ctx.id = id; ctx.number = number; ctx.customer_id = customer_id; + ctx.total_ore = total; snprintf(ctx.customer, sizeof ctx.customer, "%s", cust ? cust : ""); - int ret = tui_pager_hook("Faktura", (const char *)t.p, - "p = visa PDF s = skicka", 0, inv_key, - &ctx); + if (paid_date && *paid_date) + snprintf(ctx.paid_date, sizeof ctx.paid_date, "%s", paid_date); + char ahint[192]; + snprintf(ahint, sizeof ahint, + "p = visa PDF s = skicka u = duplicera%s", + ctx.paid_date[0] ? "" : " b = kvittera betalning"); + int ret = tui_pager_hook("Faktura", (const char *)t.p, ahint, 0, + inv_key, &ctx); free(cust); free(status); free(date); @@ -659,6 +1033,8 @@ static void invoices_detail(struct app *a, int64_t id) free(ocr); free(sent_at); free(sent_to); + free(paid_date); + free(paid_ser); buf_free(&t); free(resp); if (ret != 1) @@ -890,7 +1266,7 @@ static char *iform_draft_args(struct iform *f, char *err, size_t errn) yyjson_mut_obj_add_strcpy(d, o, "our_ref", f->our_ref); yyjson_mut_obj_add_strcpy(d, o, "notes", f->notes); yyjson_mut_val *arr = yyjson_mut_arr(d); - int used = 0; + int used = 0, priced = 0; for (int i = 0; i < f->rt.nrows; i++) { struct irow *r = &f->rows[i]; if (!r->description[0] && !r->quantity[0] && !r->unit[0] && @@ -901,6 +1277,13 @@ static char *iform_draft_args(struct iform *f, char *err, size_t errn) yyjson_mut_doc_free(d); return NULL; } + if (!r->quantity[0] && !r->unit[0] && !r->price[0] && !r->vat[0]) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr); + yyjson_mut_obj_add_strcpy(d, ro, "description", r->description); + yyjson_mut_obj_add_bool(d, ro, "text", true); + used++; + continue; + } const char *qty = r->quantity[0] ? r->quantity : "1"; if (!qty_is_valid(qty)) { snprintf(err, errn, @@ -937,12 +1320,19 @@ static char *iform_draft_args(struct iform *f, char *err, size_t errn) yyjson_mut_obj_add_strcpy(d, ro, "note", r->note); yyjson_mut_obj_add_strcpy(d, ro, "vat_code", vat); used++; + priced++; } if (used == 0) { snprintf(err, errn, "Minst en rad krävs."); yyjson_mut_doc_free(d); return NULL; } + if (priced == 0) { + snprintf(err, errn, + "Minst en prissatt rad krävs (fritextrader saknar belopp)."); + yyjson_mut_doc_free(d); + return NULL; + } yyjson_mut_obj_add_val(d, o, "rows", arr); char *out = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); @@ -967,7 +1357,7 @@ static void iform_preview(struct iform *f) } char *b64 = jstr_dup(resp, "result.content_base64"); if (b64) { - pdf_save_and_open(b64, "Faktura förhandsvisning.pdf", "Faktura PDF"); + file_save_and_open(b64, "Faktura förhandsvisning.pdf", "Faktura PDF"); free(b64); } free(resp); @@ -1021,33 +1411,8 @@ static int64_t iform_issue(struct iform *f) return id; } -static int64_t invoices_new(struct app *a) +static int64_t invoices_form_run(struct iform *f) { - struct iform f; - memset(&f, 0, sizeof f); - f.a = a; - today_iso(f.date, sizeof f.date); - snprintf(f.delivery, sizeof f.delivery, "%s", f.date); - snprintf(f.due, sizeof f.due, "%s", f.date); - snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due); - if (iform_load_customers(&f) != 0) { - iform_customers_free(&f); - return 0; - } - char *resp = - client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); - if (resp && client_ok(resp)) { - char *v = jstr_dup(resp, "result.invoice_our_ref"); - if (v) { - snprintf(f.our_ref, sizeof f.our_ref, "%s", v); - free(v); - } - } - free(resp); - if (f.ncust > 0) { - f.cust_sel = 0; - iform_customer_changed(&f); - } struct tui_rt_col cols[6] = { { "Beskrivning", 2, 34, TUI_RT_EDIT }, { "Antal", 37, 8, TUI_RT_EDIT }, @@ -1056,58 +1421,181 @@ static int64_t invoices_new(struct app *a) { "Moms", 68, 14, TUI_RT_EDIT }, { "Anm", 83, 32, TUI_RT_EDIT }, }; - tui_rt_init(&f.rt, 1, IFORM_ROWS, 6, cols, 0, iform_cell, NULL, &f); - tui_rt_set_key(&f.rt, iform_key); + tui_rt_init(&f->rt, 1, IFORM_ROWS, 6, cols, 0, iform_cell, NULL, f); + tui_rt_set_key(&f->rt, iform_key); struct tui_form_field ff[7]; memset(ff, 0, sizeof ff); ff[0].label = "Kund"; ff[0].kind = TUI_F_CHOICE; - ff[0].choices = (const char *const *)f.cust_names; - ff[0].nchoices = (int)f.ncust; - ff[0].choice = &f.cust_sel; + ff[0].choices = (const char *const *)f->cust_names; + ff[0].nchoices = (int)f->ncust; + ff[0].choice = &f->cust_sel; ff[1].label = "Fakturadatum"; - ff[1].value = f.date; - ff[1].cap = sizeof f.date; + ff[1].value = f->date; + ff[1].cap = sizeof f->date; ff[1].kind = TUI_F_DATE; ff[2].label = "Förfallodatum"; - ff[2].value = f.due; - ff[2].cap = sizeof f.due; + ff[2].value = f->due; + ff[2].cap = sizeof f->due; ff[2].kind = TUI_F_DATE; ff[3].label = "Leveransdatum"; - ff[3].value = f.delivery; - ff[3].cap = sizeof f.delivery; + ff[3].value = f->delivery; + ff[3].cap = sizeof f->delivery; ff[3].kind = TUI_F_DATE; ff[4].label = "Er referens"; - ff[4].value = f.your_ref; - ff[4].cap = sizeof f.your_ref; + ff[4].value = f->your_ref; + ff[4].cap = sizeof f->your_ref; ff[4].kind = TUI_F_TEXT; ff[5].label = "Vår referens"; - ff[5].value = f.our_ref; - ff[5].cap = sizeof f.our_ref; + ff[5].value = f->our_ref; + ff[5].cap = sizeof f->our_ref; ff[5].kind = TUI_F_TEXT; ff[6].label = "Fritext"; - ff[6].value = f.notes; - ff[6].cap = sizeof f.notes; + ff[6].value = f->notes; + ff[6].cap = sizeof f->notes; ff[6].kind = TUI_F_TEXT; - tui_rt_set_fields(&f.rt, ff, 7); + tui_rt_set_fields(&f->rt, ff, 7); + tui_rt_normalize(&f->rt); const char *hint = "Enter = välj/ändra Tab = byta fält F5 = förhandsvisa" - " ^Enter/F9 = utfärda Esc = avbryt"; + " F9 = utfärda Esc = avbryt"; int64_t out = 0; for (;;) { - int rr = tui_rt_run("Ny faktura", &f.rt, hint); + int rr = tui_rt_run("Ny faktura", &f->rt, hint); if (rr == -1) break; if (rr == -2) { - iform_preview(&f); + iform_preview(f); continue; } if (rr == -3) { - out = iform_issue(&f); + out = iform_issue(f); if (out > 0) break; } } + return out; +} + +static int64_t invoices_new(struct app *a) +{ + struct iform f; + memset(&f, 0, sizeof f); + f.a = a; + today_iso(f.date, sizeof f.date); + snprintf(f.delivery, sizeof f.delivery, "%s", f.date); + snprintf(f.due, sizeof f.due, "%s", f.date); + snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due); + if (iform_load_customers(&f) != 0) { + iform_customers_free(&f); + return 0; + } + char *resp = + client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); + if (resp && client_ok(resp)) { + char *v = jstr_dup(resp, "result.invoice_our_ref"); + if (v) { + snprintf(f.our_ref, sizeof f.our_ref, "%s", v); + free(v); + } + } + free(resp); + if (f.ncust > 0) { + f.cust_sel = 0; + iform_customer_changed(&f); + } + int64_t out = invoices_form_run(&f); + iform_customers_free(&f); + return out; +} + +/* Duplicates an issued invoice: same customer, rows and references, with + the dates reset (invoice and delivery today, due today + payment days). */ +static int64_t invoices_new_from(struct app *a, int64_t id) +{ + char iargs[64]; + snprintf(iargs, sizeof iargs, "{\"id\":%lld}", (long long)id); + char *resp = + client_rpc(&a->conn, "invoice.get", a->session, a->org, iargs); + if (!resp || !client_ok(resp)) { + show_error("Faktura", resp); + free(resp); + return 0; + } + struct iform f; + memset(&f, 0, sizeof f); + f.a = a; + today_iso(f.date, sizeof f.date); + snprintf(f.delivery, sizeof f.delivery, "%s", f.date); + snprintf(f.due, sizeof f.due, "%s", f.date); + snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due); + if (iform_load_customers(&f) != 0) { + iform_customers_free(&f); + free(resp); + return 0; + } + int64_t cid = jint_val(resp, "result.customer_id", 0); + int found = -1; + for (size_t i = 0; i < f.ncust; i++) + if (f.cust_ids[i] == cid) { + found = (int)i; + break; + } + if (found < 0) { + tui_message("Ny faktura", + "Kunden är arkiverad eller finns inte kvar."); + iform_customers_free(&f); + free(resp); + return 0; + } + f.cust_sel = found; + iform_customer_changed(&f); + char *v = jstr_dup(resp, "result.our_ref"); + if (v) { + snprintf(f.our_ref, sizeof f.our_ref, "%s", v); + free(v); + } + v = jstr_dup(resp, "result.notes"); + if (v) { + snprintf(f.notes, sizeof f.notes, "%s", v); + free(v); + } + size_t nrows = jarr_size(resp, "result.rows"); + if (nrows > IFORM_ROWS) + nrows = IFORM_ROWS; + for (size_t i = 0; i < nrows; i++) { + struct irow *r = &f.rows[i]; + char path[64]; + snprintf(path, sizeof path, "result.rows.%zu.description", i); + char *desc = jstr_dup(resp, path); + if (desc) + snprintf(r->description, sizeof r->description, "%s", desc); + snprintf(path, sizeof path, "result.rows.%zu.is_text", i); + if (jbool_val(resp, path, 0)) { + free(desc); + continue; + } + snprintf(path, sizeof path, "result.rows.%zu.quantity_milli", i); + int64_t qm = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.rows.%zu.unit", i); + char *unit = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.rows.%zu.unit_price_ore", i); + int64_t price = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.rows.%zu.vat_code", i); + char *vat = jstr_dup(resp, path); + qty_sv(qm, r->quantity, sizeof r->quantity); + if (unit) + snprintf(r->unit, sizeof r->unit, "%s", unit); + char pbuf[24]; + tui_kr_format(price, pbuf, sizeof pbuf); + snprintf(r->price, sizeof r->price, "%s", pbuf); + snprintf(r->vat, sizeof r->vat, "%s", vat_label(vat)); + free(desc); + free(unit); + free(vat); + } + free(resp); + int64_t out = invoices_form_run(&f); iform_customers_free(&f); return out; } @@ -1187,18 +1675,25 @@ void invoices_screen(struct app *a) int64_t total = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.status", i); char *status = jstr_dup(resp, path); - char nbuf[32], cbuf[256], abuf[40]; + snprintf(path, sizeof path, "result.items.%zu.paid_date", i); + char *paid = jstr_dup(resp, path); + char nbuf[32], cbuf[256], abuf[40], stbuf[64]; snprintf(nbuf, sizeof nbuf, "Faktura %lld", (long long)number); snprintf(cbuf, sizeof cbuf, "%s", cust ? cust : ""); tui_pad_field(cbuf, sizeof cbuf, 28); tui_amt_col(abuf, sizeof abuf, 14, total); + if (paid && *paid) + snprintf(stbuf, sizeof stbuf, "betald %s", paid); + else + snprintf(stbuf, sizeof stbuf, "%s", + invoice_status_sv(status)); snprintf(line, sizeof line, "%s %s %s %s %s", nbuf, - date ? date : "", cbuf, abuf, - invoice_status_sv(status)); + date ? date : "", cbuf, abuf, stbuf); items[i] = xstrdup(line); free(date); free(cust); free(status); + free(paid); } free(resp); items[n] = xstrdup("+ Ny faktura (^N)"); diff --git a/clients/screens_payroll.c b/clients/screens_payroll.c index 23d765f..8a27ec8 100644 --- a/clients/screens_payroll.c +++ b/clients/screens_payroll.c @@ -898,7 +898,7 @@ static void payroll_run_payslip(struct payroll_run *r) char *fname = jstr_dup(resp, "result.filename"); free(resp); if (b64 && fname) - pdf_save_and_open(b64, fname, "Lönebesked"); + file_save_and_open(b64, fname, "Lönebesked"); free(b64); free(fname); } @@ -1101,7 +1101,7 @@ static int64_t payroll_run_screen(struct app *a, int64_t id) { status, -1, NULL }, { totals, -1, NULL }, { "Förhandsvisa (F5)", 1, NULL }, - { "Bokför körning (^Enter/F9)", post_reason ? 0 : 1, + { "Bokför körning (F9)", post_reason ? 0 : 1, post_reason }, { "Lönebesked (PDF)", payslip_reason ? 0 : 1, payslip_reason }, { "AGI-underlag", agi_reason ? 0 : 1, agi_reason }, @@ -1124,7 +1124,7 @@ static int64_t payroll_run_screen(struct app *a, int64_t id) snprintf(title, sizeof title, "Ny lönekörning"); int editable = can_write && !posted; tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför" - " F5 = förhandsvisa ^Enter/F9 = bokför" + " F5 = förhandsvisa F9 = bokför" " Esc/q = tillbaka ^C = avsluta"); int ret = tui_form_run_actions(title, ff, 2, editable, acts, 7, NULL, &focus); diff --git a/clients/screens_settings.c b/clients/screens_settings.c index 8a31620..46ba201 100644 --- a/clients/screens_settings.c +++ b/clients/screens_settings.c @@ -199,6 +199,7 @@ struct setting_field { const char *const *choices; int nchoices; int mask; + int invoice_number; }; static void setting_save(struct app *a, const struct setting_field *f, @@ -218,9 +219,11 @@ static void setting_save(struct app *a, const struct setting_field *f, char *rr = client_rpc(&a->conn, "settings.set", a->session, a->org, args); free(args); if (rr && client_ok(rr)) { - if (strcmp(f->key, "default_series") == 0) + if (strcmp(f->key, "series_voucher") == 0) snprintf(a->default_series, sizeof a->default_series, "%s", value); + else if (strcmp(f->key, "series_ib") == 0) + snprintf(a->ib_series, sizeof a->ib_series, "%s", value); else if (strcmp(f->key, "attachment_dir") == 0) snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", value); @@ -283,12 +286,42 @@ static void settings_form(struct app *a, const char *title, acts[0].enabled = -1; } free(resp); + for (int i = 0; i < n; i++) { + if (!fields[i].invoice_number) + continue; + char *seq = client_rpc(&a->conn, "invoice.sequence_get", + a->session, a->org, "{}"); + if (seq && client_ok(seq)) + snprintf(vals[i], 512, "%lld", + (long long)jint_val(seq, "result.next_number", 1)); + free(seq); + } tui_form_hint("upp/ned/Home/End Enter = ändra F5 = uppdatera" " Esc/q = tillbaka ^C = avsluta"); int r = tui_form_run_actions(title, ff, n, can_edit, acts, show_password_status ? 1 : 0, NULL, focus); - if (r >= 0 && r < n) { + if (r >= 0 && r < n && fields[r].invoice_number) { + if (!(a->role[0] && strcmp(a->role, "owner") == 0)) { + tui_message(title, "Kräver ägarbehörighet."); + } else { + char *end = NULL; + long v = strtol(vals[r], &end, 10); + if (!end || *end || v < 1) { + tui_message(title, + "Nästa fakturanummer måste vara ett positivt" + " heltal."); + } else { + char args[64]; + snprintf(args, sizeof args, "{\"next_number\":%ld}", v); + char *rr = client_rpc(&a->conn, "invoice.sequence_set", + a->session, a->org, args); + if (!rr || !client_ok(rr)) + show_error(title, rr); + free(rr); + } + } + } else if (r >= 0 && r < n) { const char *val = vals[r]; if (fields[r].choices) val = fields[r].choices[choice[r]]; @@ -304,21 +337,30 @@ static void settings_form(struct app *a, const char *title, static const char *const SECURITY_CHOICES[] = { "starttls", "tls", "plain" }; static const struct setting_field INVOICE_SETTINGS[] = { - { "default_series", "Standardserie", NULL, 0, 0 }, - { "invoice_receivable_account", "Fordringskonto", NULL, 0, 0 }, - { "invoice_revenue_account", "Intäktskonto", NULL, 0, 0 }, - { "invoice_bankgiro", "Bankgiro", NULL, 0, 0 }, - { "invoice_our_ref", "Vår referens (faktura)", NULL, 0, 0 }, + { "invoice_next_number", "Nästa fakturanummer", NULL, 0, 0, 1 }, + { "invoice_receivable_account", "Fordringskonto", NULL, 0, 0, 0 }, + { "invoice_revenue_account", "Intäktskonto", NULL, 0, 0, 0 }, + { "invoice_bankgiro", "Bankgiro", NULL, 0, 0, 0 }, + { "invoice_our_ref", "Vår referens (faktura)", NULL, 0, 0, 0 }, + { "document_header_color", "Färg dokumenthuvud (#rrggbb)", NULL, 0, 0, 0 }, +}; + +static const struct setting_field SERIES_SETTINGS[] = { + { "series_voucher", "Manuella verifikat", NULL, 0, 0, 0 }, + { "series_invoice", "Fakturor", NULL, 0, 0, 0 }, + { "series_payroll", "Lön", NULL, 0, 0, 0 }, + { "series_bokslut", "Bokslut", NULL, 0, 0, 0 }, + { "series_ib", "Ingående balans", NULL, 0, 0, 0 }, }; static const struct setting_field SMTP_SETTINGS[] = { - { "smtp_host", "SMTP-server", NULL, 0, 0 }, - { "smtp_port", "SMTP-port", NULL, 0, 0 }, - { "smtp_user", "SMTP-användare", NULL, 0, 0 }, - { "smtp_from", "SMTP-avsändare", NULL, 0, 0 }, - { "smtp_reply_to", "SMTP-svarsadress", NULL, 0, 0 }, - { "smtp_security", "SMTP-säkerhet", SECURITY_CHOICES, 3, 0 }, - { "smtp_password", "SMTP-lösenord", NULL, 0, 1 }, + { "smtp_host", "SMTP-server", NULL, 0, 0, 0 }, + { "smtp_port", "SMTP-port", NULL, 0, 0, 0 }, + { "smtp_user", "SMTP-användare", NULL, 0, 0, 0 }, + { "smtp_from", "SMTP-avsändare", NULL, 0, 0, 0 }, + { "smtp_reply_to", "SMTP-svarsadress", NULL, 0, 0, 0 }, + { "smtp_security", "SMTP-säkerhet", SECURITY_CHOICES, 3, 0, 0 }, + { "smtp_password", "SMTP-lösenord", NULL, 0, 1, 0 }, }; static int settings_can_write(struct app *a) @@ -343,6 +385,14 @@ static void smtp_settings_screen(struct app *a) settings_can_write(a), 1, &sel); } +static void series_settings_screen(struct app *a) +{ + static int sel = 0; + settings_form(a, "Verifikationsserier", SERIES_SETTINGS, + (int)(sizeof SERIES_SETTINGS / sizeof SERIES_SETTINGS[0]), + settings_can_write(a), 0, &sel); +} + static const char *const SYSTEM_ITEMS[] = { "Skattetabeller", "Revision", @@ -435,6 +485,7 @@ static void company_data_screen(struct app *a) static const char *const COMPANY_ITEMS[] = { "Företagsuppgifter", "Fakturauppgifter", + "Verifikationsserier", "E-post (SMTP)", "Styrelseledamöter", MK_SECTION "Register", @@ -462,18 +513,21 @@ void company_screen(struct app *a) invoice_settings_screen(a); break; case 2: - smtp_settings_screen(a); + series_settings_screen(a); break; case 3: + smtp_settings_screen(a); + break; + case 4: board_screen(a); break; - case 5: + case 6: employees_screen(a); break; - case 6: + case 7: customers_screen(a); break; - case 7: + case 8: rules_screen(a); break; default: diff --git a/clients/screens_templates.c b/clients/screens_templates.c index 5777742..e55028d 100644 --- a/clients/screens_templates.c +++ b/clients/screens_templates.c @@ -234,7 +234,7 @@ static int template_form(struct app *a, const char *load_name) tui_rt_set_fields(&tf.rt, ff, 3); const char *title = load_name ? "Redigera mall" : "Ny mall"; const char *hint = "Enter = ändra fält Tab = byta fält F5 = validera" - " ^X = rensa rad ^Enter/F9 = spara Esc = avbryt"; + " ^X = rensa rad F9 = spara Esc = avbryt"; for (;;) { int rr = tui_rt_run(title, &tf.rt, hint); if (rr == -1) diff --git a/clients/screens_vouchers.c b/clients/screens_vouchers.c index 5e1b006..4cb6d9d 100644 --- a/clients/screens_vouchers.c +++ b/clients/screens_vouchers.c @@ -137,7 +137,7 @@ static int vd_key(void *ud, int ch) 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]); + attachment_view_or_download(a, aids[asel]); } else if (asel == -6 && acursor >= 0 && (size_t)acursor < ctx->natts) { char q[320]; @@ -743,6 +743,23 @@ static int64_t vform_post(struct vform *vf, int dry) replayed ? " — redan bokfört (idempotent)" : ""); free(ser); free(r); + if (strcmp(vf->series, a->default_series) != 0) { + yyjson_mut_doc *sd = yyjson_mut_doc_new(NULL); + yyjson_mut_val *so = yyjson_mut_obj(sd); + yyjson_mut_doc_set_root(sd, so); + yyjson_mut_obj_add_strcpy(sd, so, "key", "series_voucher"); + yyjson_mut_obj_add_strcpy(sd, so, "value", vf->series); + char *sargs = yyjson_mut_write(sd, 0, NULL); + yyjson_mut_doc_free(sd); + if (sargs) { + char *sr = client_rpc(&a->conn, "settings.set", + a->session, a->org, sargs); + free(sr); + free(sargs); + } + snprintf(a->default_series, sizeof a->default_series, "%s", + vf->series); + } free(args); return id; } @@ -789,21 +806,33 @@ int64_t vouchers_new_prefill(struct app *a, const struct voucher_prefill *p) if (p->description && *p->description) snprintf(vf.desc, sizeof vf.desc, "%s", p->description); if (p->bank_account && *p->bank_account) { - snprintf(vf.rows[0].account, sizeof vf.rows[0].account, "%s", - p->bank_account); int64_t amt = p->amount_ore < 0 ? -p->amount_ore : p->amount_ore; char tmp[32]; tui_kr_format(amt, tmp, sizeof tmp); - if (p->amount_ore > 0) + snprintf(vf.rows[0].account, sizeof vf.rows[0].account, "%s", + p->bank_account); + if (p->counter_account && *p->counter_account && + p->amount_ore > 0) { snprintf(vf.rows[0].debit, sizeof vf.rows[0].debit, "%s", tmp); - else if (p->amount_ore < 0) + snprintf(vf.rows[1].account, sizeof vf.rows[1].account, "%s", + p->counter_account); + snprintf(vf.rows[1].credit, sizeof vf.rows[1].credit, "%s", + tmp); + } else if (p->amount_ore > 0) { + snprintf(vf.rows[0].debit, sizeof vf.rows[0].debit, "%s", + tmp); + } else if (p->amount_ore < 0) { snprintf(vf.rows[0].credit, sizeof vf.rows[0].credit, "%s", tmp); + } } - if (p->description && *p->description) + if (p->description && *p->description) { snprintf(vf.rows[0].text, sizeof vf.rows[0].text, "%s", p->description); + snprintf(vf.rows[1].text, sizeof vf.rows[1].text, "%s", + p->description); + } } int text_w = COLS - 60; if (text_w < 8) @@ -836,7 +865,7 @@ int64_t vouchers_new_prefill(struct app *a, const struct voucher_prefill *p) tui_rt_normalize(&vf.rt); const char *hint = "Enter = ändra fält Tab = byta fält F4 = mall" " ^F = bifoga fil F5 = validera ^X = rensa rad" - " ^Enter/F9 = bokför Esc = avbryt"; + " F9 = bokför Esc = avbryt"; for (;;) { int rr = tui_rt_run("Nytt verifikat", &vf.rt, hint); if (rr == -1) diff --git a/clients/tui.c b/clients/tui.c index 30761a8..13da593 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -17,6 +17,12 @@ static int (*g_in)(void) = getch; static void (*g_frame)(const char *title); static void (*g_hints)(const char *hints); static void (*g_quit)(void); +static const char *g_list_hint_extra; + +void tui_list_hint_extra(const char *extra) +{ + g_list_hint_extra = extra; +} void tui_set_input(int (*fn)(void)) { @@ -1082,6 +1088,10 @@ int tui_select_list_hook(const char *title, char **items, int n, int start, allow_refresh ? " F5 = uppdatera" : "", allow_new ? " ^N = ny" : "", dbuf, allow_toggle ? " ^A = öppna/stäng" : ""); + if (g_list_hint_extra && *g_list_hint_extra) { + size_t hl = strlen(hint); + snprintf(hint + hl, sizeof hint - hl, " %s", g_list_hint_extra); + } list_draw(title, items, n, &v, hint); int ch = in_key(); int r = tui_nav_key(&v, ch, allow_new, archive_action != NULL, @@ -1190,6 +1200,7 @@ int tui_menu(const char *title, const char *const *items, int n, /* ------------------------------------------------------------------ */ static const char *g_form_hint; +static const char *g_form_hint_extra; static char g_status[512]; static char g_right[160]; @@ -1198,6 +1209,11 @@ void tui_form_hint(const char *hint) g_form_hint = hint; } +void tui_form_hint_extra(const char *extra) +{ + g_form_hint_extra = extra; +} + void tui_set_status(const char *status, const char *right) { snprintf(g_status, sizeof g_status, "%s", status ? status : ""); @@ -1531,6 +1547,174 @@ void tui_form_act_label(const struct tui_form_action *a, char *buf, size_t n) } } +static void action_key_name(int key, char *buf, size_t n) +{ + if (key >= KEY_F(1) && key <= KEY_F(12)) + snprintf(buf, n, "F%d", key - KEY_F(0)); + else if (key == TUI_KEY_CTRL_N) + snprintf(buf, n, "^N"); + else if (key == TUI_KEY_CTRL_X) + snprintf(buf, n, "^X"); + else if (key == 27) + snprintf(buf, n, "Esc"); + else if (key == '\n' || key == '\r' || key == KEY_ENTER) + snprintf(buf, n, "Enter"); + else if (key > 0 && key < 27) + snprintf(buf, n, "^%c", 'A' + key - 1); + else + snprintf(buf, n, "?"); +} + +void tui_action_label(const struct tui_action *a, char *buf, size_t n) +{ + const char *label = a && a->label ? a->label : ""; + + if (a && a->enabled == 0) { + const char *reason = + a->reason && *a->reason ? a->reason : "otillgänglig"; + snprintf(buf, n, "%s (%s)", label, reason); + } else { + snprintf(buf, n, "%s", label); + } +} + +void tui_action_hint(const struct tui_action *acts, int n, int max, char *buf, + size_t cap) +{ + size_t o = 0; + int shown = 0; + + if (!buf || cap == 0) + return; + buf[0] = '\0'; + for (int i = 0; i < n && (max <= 0 || shown < max); i++) { + char key[16], item[200]; + size_t len; + + if (acts[i].enabled != 1 || !acts[i].key) + continue; + action_key_name(acts[i].key, key, sizeof key); + snprintf(item, sizeof item, "%s%s = %s", shown ? " " : "", key, + acts[i].label ? acts[i].label : ""); + len = strlen(item); + if (o + len + 1 > cap) + break; + memcpy(buf + o, item, len + 1); + o += len; + shown++; + } + const char *more = "F2 = fler"; + size_t ml = strlen(more); + if (o + ml + (o ? 3 : 0) + 1 <= cap) + snprintf(buf + o, cap - o, "%s%s", o ? " " : "", more); +} + +int tui_action_menu(const char *title, const struct tui_action *acts, int n, + int *focus) +{ + struct tui_list_nav v; + unsigned char *ok; + char **items; + int ret = -1; + + if (!acts || n <= 0) + return -1; + memset(&v, 0, sizeof v); + v.n = n; + ok = xcalloc((size_t)n, 1); + items = xcalloc((size_t)n, sizeof(char *)); + for (int i = 0; i < n; i++) { + char line[192]; + + tui_action_label(&acts[i], line, sizeof line); + items[i] = xstrdup(line); + ok[i] = acts[i].enabled != -1; + } + v.selectable = ok; + v.sel = focus && *focus >= 0 && *focus < n ? *focus : 0; + if (!ok[v.sel]) + v.sel = nav_snap(&v, v.sel, 1); + v.numw = tui_num_width(n); + if (v.numw < 2) + v.numw = 2; + v.view = LINES - 4; + snprintf(v.gotolabel, sizeof v.gotolabel, "Gå till nummer: "); + for (;;) { + int num = 0; + + if (v.sel < 0 || v.sel >= n) { + v.sel = nav_snap(&v, 0, 1); + if (v.sel < 0) + break; + } + if (focus) + *focus = v.sel; + list_view_sync(&v); + if (g_frame) + g_frame(title); + for (int i = 0; i < v.top && i < n; i++) + if (ok[i]) + num++; + for (int idx = v.top; idx < n && idx < v.top + v.view; idx++) { + char line[832]; + if (!ok[idx]) { + snprintf(line, sizeof line, "%*s %s", v.numw, "", + items[idx]); + attron(tui_style_attrs(TUI_DIM, g_colours, 0)); + mvaddnstr(3 + (idx - v.top), 4, line, COLS - 6); + attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); + continue; + } + num++; + snprintf(line, sizeof line, "%*d. %s", v.numw, num, items[idx]); + if (acts[idx].enabled != 1) + attron(tui_style_attrs(TUI_DIM, g_colours, 0)); + if (idx == v.sel) + attron(A_REVERSE); + mvaddnstr(3 + (idx - v.top), 4, line, COLS - 6); + if (idx == v.sel) + attroff(A_REVERSE); + if (acts[idx].enabled != 1) + attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); + } + if (v.goto_active) { + move(LINES - 2, 2); + tui_style(TUI_ACCENT); + printw("%s%s", v.gotolabel, v.gotobuf); + tui_style_reset(); + clrtoeol(); + } else { + move(LINES - 2, 2); + clrtoeol(); + } + if (g_hints) + g_hints("upp/ned, 1-9 = hoppa, g = gå till, PgUp/PgDn, Home/End," + " Enter = utför Esc/q = avbryt"); + refresh(); + int ch = in_key(); + if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { + if (!ok[v.sel]) + continue; + if (acts[v.sel].enabled != 1) { + tui_message(title, "%s", + acts[v.sel].reason && *acts[v.sel].reason + ? acts[v.sel].reason + : "Otillgänglig."); + continue; + } + ret = v.sel; + break; + } + if (tui_nav_key(&v, ch, 0, 0, 0, 0) == -1) + break; + } + for (int i = 0; i < n; i++) + free(items[i]); + free(items); + free(ok); + return ret; +} + /* Action rows sit under the fields, one blank line below them. */ static void form_draw_actions(const struct tui_form_action *acts, int na, int nf, int sel) @@ -1615,7 +1799,7 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, h = na > 0 ? (can_edit ? "upp/ned/Tab = flytta Enter = ändra/utför" - " F5 = uppdatera ^Enter/F9 = spara" + " F5 = uppdatera F9 = spara" " Esc/q = tillbaka ^C = avsluta" : "upp/ned/Tab = flytta Enter = utför" " F5 = uppdatera Esc/q = tillbaka" @@ -1623,11 +1807,16 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, " (endast behöriga kan ändra)") : (can_edit ? "upp/ned/Home/End Enter = ändra" - " F5 = uppdatera ^Enter/F9 = spara" + " F5 = uppdatera F9 = spara" " Esc/q = tillbaka ^C = avsluta" : "upp/ned/Home/End F5 = uppdatera" " Esc/q = tillbaka ^C = avsluta" " (endast behöriga kan ändra)"); + char hbuf[640]; + if (g_form_hint_extra && *g_form_hint_extra && + snprintf(hbuf, sizeof hbuf, "%s %s", h, g_form_hint_extra) < + (int)sizeof hbuf) + h = hbuf; tui_hints(h); g_form_hint = NULL; refresh(); diff --git a/clients/tui.h b/clients/tui.h index 6df27fb..1785a32 100644 --- a/clients/tui.h +++ b/clients/tui.h @@ -191,10 +191,33 @@ 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 *focus); void tui_form_hint(const char *hint); /* overrides the footer for the next run */ +/* Extra text appended to the form/list footer until changed (NULL clears). */ +void tui_form_hint_extra(const char *extra); +void tui_list_hint_extra(const char *extra); 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 *focus); +/* --- action registry (docs/TUI-GUIDELINES.md "Interaction model") --- */ +struct tui_action { + const char *id; /* stable, e.g. "customer.archive" */ + const char *label; /* Swedish UI text */ + int key; /* accelerator; 0 = menu only */ + int enabled; /* 1 runnable, 0 dim + reason, -1 heading */ + const char *reason; /* why a disabled action is dim */ +}; + +/* "label", or "label (reason)" when the action is disabled. */ +void tui_action_label(const struct tui_action *a, char *buf, size_t n); +/* Builds "F9 = spara F2 = fler", showing at most max keyed actions. */ +void tui_action_hint(const struct tui_action *acts, int n, int max, + char *buf, size_t cap); +/* Modal action list: arrows/Tab/Home/End/PgUp/PgDn/1-9/g navigate, Enter + runs (a disabled action shows its reason), Esc cancels. Returns the + chosen index or -1. *focus remembers the row. */ +int tui_action_menu(const char *title, const struct tui_action *acts, int n, + int *focus); + /* --- action list in a form (tui_form_run_actions) --- */ /* enabled: 1 = selectable and runs; 0 = dimmed but selectable (Enter shows disabled_reason in a message); -1 = non-selectable heading/status row, diff --git a/clients/ui.c b/clients/ui.c index ca6906f..cda0883 100644 --- a/clients/ui.c +++ b/clients/ui.c @@ -217,12 +217,17 @@ void app_refresh_context(struct app *a) free(resp); snprintf(a->default_series, sizeof a->default_series, "%s", "A"); + snprintf(a->ib_series, sizeof a->ib_series, "%s", "IB"); resp = client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); if (resp && client_ok(resp)) { - char *ser = jstr_dup(resp, "result.default_series"); + char *ser = jstr_dup(resp, "result.series_voucher"); if (ser && *ser) snprintf(a->default_series, sizeof a->default_series, "%s", ser); free(ser); + char *ib = jstr_dup(resp, "result.series_ib"); + if (ib && *ib) + snprintf(a->ib_series, sizeof a->ib_series, "%s", ib); + free(ib); } free(resp); @@ -467,26 +472,9 @@ static void safe_fname(const char *in, char *out, size_t n) out[o] = '\0'; } -static int write_pdf_b64(const char *b64, const char *path) -{ - unsigned char *data = NULL; - size_t len = 0; - if (!b64 || util_b64_decode(b64, strlen(b64), &data, &len) != 0) - return -1; - FILE *fp = fopen(path, "wb"); - if (!fp) { - free(data); - return -1; - } - size_t wrote = fwrite(data, 1, len, fp); - fclose(fp); - free(data); - return wrote == len ? 0 : -1; -} - -/* Open a saved PDF with xdg-open when a desktop session is present; +/* Open a saved file with xdg-open when a desktop session is present; otherwise (or on fork failure) show where it was saved. */ -static void open_pdf(const char *path, const char *title) +static void open_saved(const char *path, const char *title) { const char *display = getenv("DISPLAY"); const char *wayland = getenv("WAYLAND_DISPLAY"); @@ -510,8 +498,8 @@ static void open_pdf(const char *path, const char *title) tui_message(title, "Sparad: %s", path); } -void pdf_save_and_open(const char *b64, const char *filename, - const char *title) +static void save_cache_and_open(const unsigned char *data, size_t n, + const char *filename, const char *title) { char dir[512], path[700], safe[600]; pdf_cache_dir(dir, sizeof dir); @@ -521,11 +509,80 @@ void pdf_save_and_open(const char *b64, const char *filename, return; } config_mkdirs(path); - if (write_pdf_b64(b64, path) != 0) { + FILE *fp = fopen(path, "wb"); + if (!fp || (n > 0 && fwrite(data, 1, n, fp) != n)) { + if (fp) + fclose(fp); tui_message(title, "Kunde inte spara %s", path); return; } - open_pdf(path, title); + fclose(fp); + open_saved(path, title); +} + +void file_save_and_open(const char *b64, const char *filename, + const char *title) +{ + unsigned char *data = NULL; + size_t n = 0; + if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) { + tui_message(title, "Kunde inte avkoda innehållet."); + return; + } + save_cache_and_open(data, n, filename, title); + free(data); +} + +/* Show one attachment: text inline in a pager, anything else through the + desktop viewer (or a message with the saved path). */ +void attachment_open(struct app *a, int64_t att_id) +{ + char args[64]; + snprintf(args, sizeof args, "{\"id\":%lld}", (long long)att_id); + char *resp = + client_rpc(&a->conn, "attachment.get", a->session, a->org, args); + if (!resp || !client_ok(resp)) { + show_error("Kunde inte hämta underlaget", resp); + free(resp); + return; + } + char *fn = jstr_dup(resp, "result.filename"); + char *mime = jstr_dup(resp, "result.mime"); + char *b64 = jstr_dup(resp, "result.content_base64"); + unsigned char *data = NULL; + size_t n = 0; + if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) { + tui_message("Underlag", "Kunde inte avkoda innehållet."); + goto done; + } + int is_text = (mime && strncmp(mime, "text/", 5) == 0) || + (n > 0 && bytes_look_text(data, n)); + if (is_text && n <= 256 * 1024) { + char *copy = xmalloc(n + 1); + memcpy(copy, data, n); + copy[n] = '\0'; + tui_pager("Underlag", copy, NULL); + free(copy); + } else { + save_cache_and_open(data, n, fn && *fn ? fn : "underlag", "Underlag"); + } +done: + free(data); + free(b64); + free(mime); + free(fn); + free(resp); +} + +/* Enter on an attachment row: granska or ladda ned. */ +void attachment_view_or_download(struct app *a, int64_t att_id) +{ + static const char *const opts[] = { "Granska", "Ladda ned…" }; + int c = tui_choice_prompt("Underlag: ", opts, 2, 0); + if (c == 0) + attachment_open(a, att_id); + else if (c == 1) + attachment_download(a, att_id); } /* Fetch one attachment, save it to a prompted path and verify its hash. diff --git a/clients/ui.h b/clients/ui.h index b37eb4f..c546c91 100644 --- a/clients/ui.h +++ b/clients/ui.h @@ -42,6 +42,7 @@ struct app { int64_t employee_sel; /* last selected employee id in the list view */ int64_t payroll_sel; /* last selected payroll run id in the list view */ char default_series[16]; + char ib_series[16]; char attachment_dir[256]; long max_attachment_bytes; char fy_label[64]; @@ -78,9 +79,11 @@ char *rpc_dry(struct app *a, const char *cmd, const char *args); char *read_file_b64(const char *path); char *file_browser(struct app *a, const char *start_dir); void attachment_download(struct app *a, int64_t att_id); +void attachment_open(struct app *a, int64_t att_id); +void attachment_view_or_download(struct app *a, int64_t att_id); int64_t pick_voucher(struct app *a); -void pdf_save_and_open(const char *b64, const char *filename, - const char *title); +void file_save_and_open(const char *b64, const char *filename, + const char *title); /* generated-document helpers (reports, bokslut) */ void buf_line(struct buf *b, const char *fmt, ...) @@ -109,6 +112,7 @@ struct voucher_prefill { const char *date; /* YYYY-MM-DD, may be NULL */ const char *description; /* voucher text, may be NULL */ const char *bank_account; /* account number for the prefilled row */ + const char *counter_account; /* with amount > 0: credit this account */ int64_t amount_ore; /* > 0 debits the bank account, < 0 credits it */ }; |
