aboutsummaryrefslogtreecommitdiff
path: root/clients
diff options
context:
space:
mode:
Diffstat (limited to 'clients')
-rw-r--r--clients/bokfctl.c26
-rw-r--r--clients/drafts.c249
-rw-r--r--clients/drafts.h49
-rw-r--r--clients/screens_attachments.c2
-rw-r--r--clients/screens_bokslut.c2
-rw-r--r--clients/screens_ib.c4
-rw-r--r--clients/screens_invoices.c799
-rw-r--r--clients/screens_payroll.c6
-rw-r--r--clients/screens_settings.c90
-rw-r--r--clients/screens_templates.c2
-rw-r--r--clients/screens_vouchers.c43
-rw-r--r--clients/tui.c193
-rw-r--r--clients/tui.h23
-rw-r--r--clients/ui.c105
-rw-r--r--clients/ui.h8
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 */
};