From e1dd7112e0346914710566660625da0f6b866e8a Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Mon, 21 Sep 2026 11:45:54 +0200 Subject: tui: payroll screens, Företag/System menu and settings split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clients/bokftui.c | 11 +- clients/screens_bokslut.c | 5 +- clients/screens_dashboard.c | 52 +- clients/screens_invoices.c | 86 +-- clients/screens_payroll.c | 1260 +++++++++++++++++++++++++++++++++++++++++++ clients/screens_settings.c | 348 ++++++------ clients/ui.c | 85 +++ clients/ui.h | 9 +- 8 files changed, 1579 insertions(+), 277 deletions(-) create mode 100644 clients/screens_payroll.c (limited to 'clients') diff --git a/clients/bokftui.c b/clients/bokftui.c index 84d1a18..a047ef5 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -35,13 +35,17 @@ static const struct scene SCENES[] = { { "bank", bank_screen }, { "invoices", invoices_screen }, { "customers", customers_screen }, + { "payroll", payroll_screen }, + { "employees", employees_screen }, + { "tax_tables", payroll_tax_tables_screen }, { "ib", ib_screen }, { "templates", templates_screen }, { "rules", rules_screen }, { "reports", reports_screen }, { "audit", audit_screen }, - { "settings", settings_screen }, { "company", company_screen }, + { "settings", company_screen }, /* ^R/--screen compatibility */ + { "system", system_settings_screen }, { "bokslut", bokslut_screen }, { "yearinfo", bokslut_screen }, /* ^R compatibility */ }; @@ -329,8 +333,9 @@ static void usage(FILE *f) " --session ID reuse an open session (or BOKFD_SESSION)\n" " --screen NAME start in a view: dashboard, vouchers, inbox,\n" " bank, ib, templates, rules, reports, bokslut\n" - " (yearinfo is an alias), audit, settings,\n" - " company, invoices, customers\n" + " (yearinfo is an alias), audit, company,\n" + " invoices, customers, payroll, employees,\n" + " tax_tables, system (settings is an alias)\n" " --version\n"); } diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c index 41649ce..df47196 100644 --- a/clients/screens_bokslut.c +++ b/clients/screens_bokslut.c @@ -560,7 +560,7 @@ static void fmt_arsredovisning(struct buf *t, const struct app *a, ar_wrap(t, description); else buf_line(t, "[Kort beskrivning av verksamheten saknas — fyll i under" - " Företagsuppgifter.]\n"); + " Bolaget.]\n"); buf_line(t, "\nBolaget har sitt säte i %s.\n", city); buf_line(t, "\n" MK_HEAD "Väsentliga händelser under räkenskapsåret\n"); if (events && *events) @@ -796,8 +796,7 @@ static void fmt_arsredovisning(struct buf *t, const struct app *a, if (board_text && *board_text) buf_line(t, "%s", board_text); else - buf_line(t, "[Lägg till styrelseledamöter under" - " Företagsuppgifter.]\n"); + buf_line(t, "[Lägg till styrelseledamöter under Bolaget.]\n"); } /* The pager text carries style markers that must not reach the saved file. */ diff --git a/clients/screens_dashboard.c b/clients/screens_dashboard.c index 0c133b2..a386dae 100644 --- a/clients/screens_dashboard.c +++ b/clients/screens_dashboard.c @@ -269,17 +269,20 @@ static void select_fiscal_year(struct app *a) } static const char *const MAIN_ITEMS[] = { MK_SECTION "Bokföring", - "Verifikat", "Underlag (inkorg)", "Bankavstämning", + "Verifikat", "Underlag (inkorg)", "Bankavstämning", "Mallar", MK_SECTION "Fakturering", "Fakturor", + MK_SECTION "Lön", + "Lönekörningar", MK_SECTION "Rapporter & bokslut", "Rapporter", "Bokslut", - MK_SECTION "Register", - "Mallar", "Kunder", "Momsregler", "Företagsuppgifter", "Inställningar", + MK_SECTION "Företag", + "Anställda", "Kunder", "Bolaget", "Momsregler", + MK_SECTION "System", + "Skattetabeller", "Revision", "Systeminställningar", MK_SECTION "Räkenskapsår", "Ingående balans", "Räkenskapsår", - MK_SECTION "Övrigt", - "Revision", "Logga ut / avsluta", + "Logga ut / avsluta", }; void dashboard(struct app *a) @@ -312,40 +315,49 @@ void dashboard(struct app *a) case 3: open_scene(a, "bank"); break; - case 5: - open_scene(a, "invoices"); + case 4: + open_scene(a, "templates"); break; - case 7: - open_scene(a, "reports"); + case 6: + open_scene(a, "invoices"); break; case 8: - open_scene(a, "bokslut"); + open_scene(a, "payroll"); break; case 10: - open_scene(a, "templates"); + open_scene(a, "reports"); break; case 11: - open_scene(a, "customers"); - break; - case 12: - open_scene(a, "rules"); + open_scene(a, "bokslut"); break; case 13: - open_scene(a, "company"); + open_scene(a, "employees"); break; case 14: - open_scene(a, "settings"); + open_scene(a, "customers"); + break; + case 15: + open_scene(a, "company"); break; case 16: - open_scene(a, "ib"); + open_scene(a, "rules"); break; - case 17: - select_fiscal_year(a); + case 18: + open_scene(a, "tax_tables"); break; case 19: open_scene(a, "audit"); break; case 20: + open_scene(a, "system"); + break; + case 22: + open_scene(a, "ib"); + break; + case 23: + select_fiscal_year(a); + break; + case 24: return; default: break; /* section header */ diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c index 3782a5f..b8cc8dd 100644 --- a/clients/screens_invoices.c +++ b/clients/screens_invoices.c @@ -41,89 +41,7 @@ static void today_iso(char *buf, size_t n) buf[len] = '\0'; } -/* Directory for generated invoice PDFs. */ -static void invoice_cache_dir(char *buf, size_t n) -{ - const char *cache = getenv("XDG_CACHE_HOME"); - if (cache && *cache) { - snprintf(buf, n, "%s/bokf", cache); - return; - } - snprintf(buf, n, "/tmp"); -} - -/* Keep a customer-supplied name from escaping the directory. */ -static void safe_fname(const char *in, char *out, size_t n) -{ - size_t o = 0; - for (const char *p = in ? in : ""; *p && o + 1 < n; p++) { - unsigned char c = (unsigned char)*p; - if (c < 32 || c == '/') - c = '-'; - out[o++] = (char)c; - } - 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; - otherwise (or on fork failure) show where it was saved. */ -static void open_pdf(const char *path) -{ - const char *display = getenv("DISPLAY"); - const char *wayland = getenv("WAYLAND_DISPLAY"); - if ((!display || !*display) && (!wayland || !*wayland)) { - tui_message("Faktura PDF", "Sparad: %s", path); - return; - } - pid_t pid = fork(); - if (pid == 0) { - pid_t gc = fork(); - if (gc == 0) { - setsid(); - execlp("xdg-open", "xdg-open", path, (char *)NULL); - _exit(127); - } - _exit(gc < 0 ? 1 : 0); - } - if (pid > 0) - waitpid(pid, NULL, 0); - else - tui_message("Faktura PDF", "Sparad: %s", path); -} - -static void save_and_open_pdf(const char *b64, const char *filename) -{ - char dir[512], path[700], safe[600]; - invoice_cache_dir(dir, sizeof dir); - safe_fname(filename, safe, sizeof safe); - if (snprintf(path, sizeof path, "%s/%s", dir, safe) >= (int)sizeof path) { - tui_message("Faktura PDF", "Sökvägen blir för lång."); - return; - } - config_mkdirs(path); - if (write_pdf_b64(b64, path) != 0) { - tui_message("Faktura PDF", "Kunde inte spara %s", path); - return; - } - open_pdf(path); -} static const char *invoice_status_sv(const char *s) { @@ -558,7 +476,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); - save_and_open_pdf(b64, fname); + pdf_save_and_open(b64, fname, "Faktura PDF"); free(b64); } free(resp); @@ -1049,7 +967,7 @@ static void iform_preview(struct iform *f) } char *b64 = jstr_dup(resp, "result.content_base64"); if (b64) { - save_and_open_pdf(b64, "Faktura förhandsvisning.pdf"); + pdf_save_and_open(b64, "Faktura förhandsvisning.pdf", "Faktura PDF"); free(b64); } free(resp); diff --git a/clients/screens_payroll.c b/clients/screens_payroll.c new file mode 100644 index 0000000..9bfaf38 --- /dev/null +++ b/clients/screens_payroll.c @@ -0,0 +1,1260 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "client.h" +#include "tui.h" +#include "formula.h" +#include "util.h" +#include "version.h" +#include "yyjson.h" +#include "ui.h" + +/* ------------------------------------------------------------------ */ +/* lön: Lönekörningar, Anställda, Skattetabeller */ +/* ------------------------------------------------------------------ */ + +static void payroll_today(char *buf, size_t n) +{ + time_t now = time(NULL); + struct tm tm; + localtime_r(&now, &tm); + char tmp[64]; + snprintf(tmp, sizeof tmp, "%04d-%02d-%02d", tm.tm_year + 1900, + tm.tm_mon + 1, tm.tm_mday); + size_t len = strlen(tmp); + if (len >= n) + len = n ? n - 1 : 0; + memcpy(buf, tmp, len); + if (n) + buf[len] = '\0'; +} + +static int payroll_can_write(struct app *a) +{ + return strcmp(a->role, "owner") == 0 || + strcmp(a->role, "bookkeeper") == 0; +} + +static int payroll_is_owner(struct app *a) +{ + return strcmp(a->role, "owner") == 0; +} + +static int payroll_digits(const char *s) +{ + if (!s || !*s) + return 0; + for (const char *p = s; *p; p++) + if (*p < '0' || *p > '9') + return 0; + return 1; +} + +static int payroll_parse_int(const char *s, int lo, int hi, int *out) +{ + if (!s || !*s) + return -1; + char *end = NULL; + long v = strtol(s, &end, 10); + if (!end || *end || v < lo || v > hi) + return -1; + *out = (int)v; + return 0; +} + +static const char *payroll_status_sv(const char *s) +{ + return s && strcmp(s, "paid") == 0 ? "betald" : "bokförd"; +} + +static void payroll_voucher_ver(struct app *a, int64_t vid, char *buf, size_t n) +{ + buf[0] = '\0'; + if (vid <= 0) + return; + char args[64]; + snprintf(args, sizeof args, "{\"id\":%lld}", (long long)vid); + char *resp = client_rpc(&a->conn, "voucher.get", a->session, a->org, args); + if (resp && client_ok(resp)) { + char *ser = jstr_dup(resp, "result.series"); + snprintf(buf, n, "%s%lld", ser ? ser : "", + (long long)jint_val(resp, "result.number", 0)); + free(ser); + } + free(resp); +} + +static void payroll_copy(char *dst, size_t n, const char *resp, + const char *path) +{ + char *v = jstr_dup(resp, path); + snprintf(dst, n, "%s", v ? v : ""); + free(v); +} + +static int payroll_period_valid(const char *p) +{ + if (!p || strlen(p) != 7 || p[4] != '-') + return 0; + for (int i = 0; i < 7; i++) { + if (i == 4) + continue; + if (p[i] < '0' || p[i] > '9') + return 0; + } + int m = (p[5] - '0') * 10 + (p[6] - '0'); + return m >= 1 && m <= 12; +} + +/* ------------------------------------------------------------------ */ +/* Anställda (employee register) */ +/* ------------------------------------------------------------------ */ + +struct employee_form { + int64_t id; + int is_new; + char name[200]; + char personal_no[16]; + char address[512]; + char postal_code[32]; + char city[128]; + char bank_account[64]; + char email[256]; + char salary_account[16]; + char tax_table[8]; + char tax_column[8]; + int64_t monthly_salary_ore; +}; + +static char *employee_form_args(const struct employee_form *f, char *err, + size_t errn) +{ + if (!f->name[0]) { + snprintf(err, errn, "Namn saknas."); + return NULL; + } + if (f->is_new && !f->personal_no[0]) { + snprintf(err, errn, "Personnummer saknas."); + return NULL; + } + int table = 0, column = 0; + if (payroll_parse_int(f->tax_table, 29, 42, &table) != 0) { + snprintf(err, errn, "Skattetabell måste vara 29–42."); + return NULL; + } + if (payroll_parse_int(f->tax_column, 1, 6, &column) != 0) { + snprintf(err, errn, "Kolumn måste vara 1–6."); + return NULL; + } + if (f->monthly_salary_ore < 0) { + snprintf(err, errn, "Månadslönen kan inte vara negativ."); + return NULL; + } + if (f->salary_account[0] && !payroll_digits(f->salary_account)) { + snprintf(err, errn, "Lönekontot får bara innehålla siffror."); + return NULL; + } + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + yyjson_mut_doc_set_root(d, o); + if (!f->is_new) + yyjson_mut_obj_add_int(d, o, "id", f->id); + yyjson_mut_obj_add_strcpy(d, o, "name", f->name); + if (f->personal_no[0]) + yyjson_mut_obj_add_strcpy(d, o, "personal_no", f->personal_no); + yyjson_mut_obj_add_strcpy(d, o, "address", f->address); + yyjson_mut_obj_add_strcpy(d, o, "postal_code", f->postal_code); + yyjson_mut_obj_add_strcpy(d, o, "city", f->city); + yyjson_mut_obj_add_strcpy(d, o, "bank_account", f->bank_account); + yyjson_mut_obj_add_strcpy(d, o, "email", f->email); + if (f->salary_account[0]) + yyjson_mut_obj_add_strcpy(d, o, "salary_account", f->salary_account); + yyjson_mut_obj_add_int(d, o, "monthly_salary_ore", f->monthly_salary_ore); + yyjson_mut_obj_add_int(d, o, "tax_table", table); + yyjson_mut_obj_add_int(d, o, "tax_column", column); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + return args; +} + +static void employee_form(struct app *a, int64_t id) +{ + static int sel = 0; + struct employee_form f; + memset(&f, 0, sizeof f); + f.id = id; + f.is_new = id <= 0; + snprintf(f.tax_table, sizeof f.tax_table, "30"); + snprintf(f.tax_column, sizeof f.tax_column, "1"); + if (!f.is_new) { + char args[64]; + snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id); + char *resp = + client_rpc(&a->conn, "employee.get", a->session, a->org, args); + if (!resp || !client_ok(resp)) { + show_error("Anställd", resp); + free(resp); + return; + } + payroll_copy(f.name, sizeof f.name, resp, "result.name"); + payroll_copy(f.personal_no, sizeof f.personal_no, resp, + "result.personal_no"); + payroll_copy(f.address, sizeof f.address, resp, "result.address"); + payroll_copy(f.postal_code, sizeof f.postal_code, resp, + "result.postal_code"); + payroll_copy(f.city, sizeof f.city, resp, "result.city"); + payroll_copy(f.bank_account, sizeof f.bank_account, resp, + "result.bank_account"); + payroll_copy(f.email, sizeof f.email, resp, "result.email"); + payroll_copy(f.salary_account, sizeof f.salary_account, resp, + "result.salary_account"); + snprintf(f.tax_table, sizeof f.tax_table, "%lld", + (long long)jint_val(resp, "result.tax_table", 30)); + snprintf(f.tax_column, sizeof f.tax_column, "%lld", + (long long)jint_val(resp, "result.tax_column", 1)); + f.monthly_salary_ore = + jint_val(resp, "result.monthly_salary_ore", 0); + f.personal_no[0] = '\0'; + free(resp); + } + for (;;) { + if (g_quit) + return; + struct tui_form_field ff[11]; + memset(ff, 0, sizeof ff); + int i = 0; + ff[i].label = "Namn"; + ff[i].value = f.name; + ff[i].cap = sizeof f.name; + ff[i].kind = TUI_F_TEXT; + i++; + ff[i].label = f.is_new ? "Personnummer" + : "Personnummer (tomt = oförändrat)"; + ff[i].value = f.personal_no; + ff[i].cap = sizeof f.personal_no; + ff[i].kind = TUI_F_TEXT; + i++; + ff[i].label = "Adress"; + ff[i].value = f.address; + ff[i].cap = sizeof f.address; + ff[i].kind = TUI_F_TEXT; + i++; + ff[i].label = "Postnummer"; + ff[i].value = f.postal_code; + ff[i].cap = sizeof f.postal_code; + ff[i].kind = TUI_F_TEXT; + i++; + ff[i].label = "Ort"; + ff[i].value = f.city; + ff[i].cap = sizeof f.city; + ff[i].kind = TUI_F_TEXT; + i++; + ff[i].label = "Bankkonto (nettolön)"; + ff[i].value = f.bank_account; + ff[i].cap = sizeof f.bank_account; + ff[i].kind = TUI_F_TEXT; + i++; + ff[i].label = "E-post (lönebesked)"; + ff[i].value = f.email; + ff[i].cap = sizeof f.email; + ff[i].kind = TUI_F_TEXT; + i++; + ff[i].label = "Lönekonto"; + ff[i].value = f.salary_account; + ff[i].cap = sizeof f.salary_account; + ff[i].kind = TUI_F_TEXT; + i++; + ff[i].label = "Månadslön (kr)"; + ff[i].kind = TUI_F_AMOUNT; + ff[i].ore = &f.monthly_salary_ore; + i++; + ff[i].label = "Skattetabell (29–42)"; + ff[i].value = f.tax_table; + ff[i].cap = sizeof f.tax_table; + ff[i].kind = TUI_F_TEXT; + i++; + ff[i].label = "Kolumn (1–6)"; + ff[i].value = f.tax_column; + ff[i].cap = sizeof f.tax_column; + ff[i].kind = TUI_F_TEXT; + i++; + const char *title = f.is_new ? "Ny anställd" : "Anställd"; + int r = tui_form_run(title, ff, i, 1, &sel); + if (r == TUI_FORM_BACK) + return; + if (r != TUI_FORM_REFRESH && r != TUI_FORM_SUBMIT) + continue; + char err[256]; + char *args = employee_form_args(&f, err, sizeof err); + if (!args) { + tui_message(title, "%s", err); + continue; + } + const char *cmd = f.is_new ? "employee.create" : "employee.update"; + char *resp = r == TUI_FORM_REFRESH ? rpc_dry(a, cmd, args) + : client_rpc(&a->conn, cmd, + a->session, a->org, + args); + free(args); + if (r == TUI_FORM_REFRESH) { + if (resp && client_ok(resp)) + tui_message(title, "Validering OK."); + else + show_error(title, resp); + free(resp); + continue; + } + if (!resp || !client_ok(resp)) { + show_error("Kunde inte spara", resp); + free(resp); + continue; + } + free(resp); + tui_message(title, f.is_new ? "Anställd skapad." : "Sparad."); + return; + } +} + +static void employee_archive(struct app *a, int64_t id, const char *name, + int active) +{ + if (!tui_confirm("Anställd", active ? "Arkivera %s?" : "Återaktivera %s?", + name ? name : "")) + return; + char args[128]; + snprintf(args, sizeof args, "{\"id\":%lld,\"active\":%s}", (long long)id, + active ? "false" : "true"); + char *r = + client_rpc(&a->conn, "employee.archive", a->session, a->org, args); + if (r && client_ok(r)) + tui_message("Anställd", active ? "Arkiverad." : "Återaktiverad."); + else + show_error("Anställd", r); + free(r); +} + +void employees_screen(struct app *a) +{ + for (;;) { + if (g_quit) + return; + char *resp = client_rpc(&a->conn, "employee.list", a->session, a->org, + "{}"); + if (!resp || !client_ok(resp)) { + show_error("Anställda", resp); + free(resp); + return; + } + size_t n = jarr_size(resp, "result.items"); + char **lines = xcalloc(n + 1, sizeof(char *)); + char **names = xcalloc(n + 1, sizeof(char *)); + int64_t *ids = xcalloc(n + 1, sizeof(int64_t)); + unsigned char *active = xcalloc(n + 1, 1); + for (size_t i = 0; i < n; i++) { + char path[64]; + snprintf(path, sizeof path, "result.items.%zu.id", i); + ids[i] = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.name", i); + names[i] = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.items.%zu.personal_no", i); + char *pn = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.items.%zu.monthly_salary_ore", + i); + int64_t salary = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.tax_table", i); + int64_t table = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.tax_column", i); + int64_t column = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.email", i); + char *email = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.items.%zu.active", i); + active[i] = (unsigned char)jbool_val(resp, path, 1); + char nbuf[224], pbuf[24], abuf[32], tbuf[16], ebuf[256]; + snprintf(nbuf, sizeof nbuf, "%s", names[i] ? names[i] : ""); + tui_pad_field(nbuf, sizeof nbuf, 24); + snprintf(pbuf, sizeof pbuf, "%s", pn ? pn : ""); + tui_pad_field(pbuf, sizeof pbuf, 15); + tui_amt_col(abuf, sizeof abuf, 12, salary); + snprintf(tbuf, sizeof tbuf, "%lld/%lld", (long long)table, + (long long)column); + snprintf(ebuf, sizeof ebuf, "%s", email ? email : ""); + char line[600]; + snprintf(line, sizeof line, "%s %s %s %s %s%s", nbuf, pbuf, abuf, + tbuf, ebuf, active[i] ? "" : " (arkiverad)"); + lines[i] = xstrdup(line); + free(pn); + free(email); + } + free(resp); + size_t start = 0; + for (size_t i = 0; i < n; i++) + if (ids[i] == a->employee_sel) { + start = i; + break; + } + lines[n] = xstrdup("+ Ny anställd (^N)"); + int cur = (int)start; + int sel = tui_select_list("Anställda", lines, (int)n + 1, cur, 1, + &cur, 1, "arkivera/återaktivera", 0); + if (cur >= 0 && (size_t)cur < n) + a->employee_sel = ids[cur]; + if (sel == -1) { + for (size_t i = 0; i <= n; i++) { + free(lines[i]); + free(names[i]); + } + free(lines); + free(names); + free(ids); + free(active); + return; + } + if (sel == -6 && cur >= 0 && (size_t)cur < n) { + employee_archive(a, ids[cur], names[cur], active[cur]); + } else if (sel == -4 || (sel >= 0 && (size_t)sel == n)) { + employee_form(a, 0); + } else if (sel >= 0 && (size_t)sel < n) { + employee_form(a, ids[sel]); + } + for (size_t i = 0; i <= n; i++) { + free(lines[i]); + free(names[i]); + } + free(lines); + free(names); + free(ids); + free(active); + } +} + +/* ------------------------------------------------------------------ */ +/* Skattetabeller */ +/* ------------------------------------------------------------------ */ + +static void tax_tables_fetch(struct app *a, int year) +{ + if (!tui_confirm("Skattetabeller", + "Hämta och ersätta skattetabellerna för %d från" + " Skatteverket?", + year)) + return; + char args[48]; + snprintf(args, sizeof args, "{\"year\":%d}", year); + char *resp = client_rpc(&a->conn, "payroll.tax_tables_fetch", a->session, + a->org, args); + if (resp && client_ok(resp)) { + tui_message("Skattetabeller", "%lld rader för %lld sparade.", + (long long)jint_val(resp, "result.rows", 0), + (long long)jint_val(resp, "result.year", 0)); + } else { + show_error("Kunde inte hämta skattetabellerna", resp); + } + free(resp); +} + +static void tax_tables_import(struct app *a, int year) +{ + char yearbuf[8]; + snprintf(yearbuf, sizeof yearbuf, "%d", year); + char *yb = tui_prompt_into(yearbuf, sizeof yearbuf, + "Inkomstår för filen: ", yearbuf, 0) + ? yearbuf + : NULL; + if (!yb || !*yb) + return; + char *end = NULL; + long y = strtol(yb, &end, 10); + if (!end || *end || y < 2000 || y > 9999) { + tui_message("Skattetabeller", "Ogiltigt inkomstår."); + return; + } + char *path = file_browser(a, a->attachment_dir); + if (!path) + return; + char *b64 = read_file_b64(path); + if (!b64) { + tui_message("Skattetabeller", "Kunde inte läsa %s", path); + free(path); + return; + } + free(path); + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_int(d, o, "year", y); + yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + free(b64); + if (!args) + return; + char *resp = client_rpc(&a->conn, "payroll.tax_tables_import", a->session, + a->org, args); + free(args); + if (resp && client_ok(resp)) { + tui_message("Skattetabeller", "%lld rader för %lld importerade.", + (long long)jint_val(resp, "result.rows", 0), + (long long)jint_val(resp, "result.year", 0)); + } else { + show_error("Kunde inte importera skattetabellerna", resp); + } + free(resp); +} + +void payroll_tax_tables_screen(struct app *a) +{ + static int sel = 4; + int owner = payroll_is_owner(a); + const char *reason = owner ? NULL : "kräver ägarbehörighet"; + for (;;) { + if (g_quit) + return; + char *resp = client_rpc(&a->conn, "payroll.tax_tables_status", + a->session, a->org, "{}"); + if (!resp || !client_ok(resp)) { + show_error("Skattetabeller", resp); + free(resp); + return; + } + int current_year = (int)jint_val(resp, "result.current_year", 0); + int stale = jbool_val(resp, "result.stale", 1); + char years[256] = "inga"; + size_t ny = jarr_size(resp, "result.stored_years"); + if (ny > 0) { + years[0] = '\0'; + size_t off = 0; + for (size_t i = 0; i < ny; i++) { + char path[48]; + snprintf(path, sizeof path, "result.stored_years.%zu", i); + int64_t y = jint_val(resp, path, 0); + size_t left = sizeof years - off; + int w = snprintf(years + off, left, "%s%lld", i ? ", " : "", + (long long)y); + if (w < 0 || (size_t)w >= left) + break; + off += (size_t)w; + } + } + char current[8]; + snprintf(current, sizeof current, "%d", current_year); + char fetched[64] = "—"; + char *fa = jstr_dup(resp, "result.fetched_at"); + if (fa && *fa) + snprintf(fetched, sizeof fetched, "%s", fa); + free(fa); + char url[1024] = "—"; + char *su = jstr_dup(resp, "result.source_url"); + if (su && *su) + snprintf(url, sizeof url, "%s", su); + free(su); + char status[160]; + if (stale) + snprintf(status, sizeof status, + "Status: skattetabeller för %d saknas", current_year); + else + snprintf(status, sizeof status, + "Status: skattetabeller för %d finns", current_year); + free(resp); + struct tui_form_field ff[4]; + memset(ff, 0, sizeof ff); + ff[0].label = "Aktuellt inkomstår"; + ff[0].value = current; + ff[0].cap = sizeof current; + ff[0].kind = TUI_F_TEXT; + ff[1].label = "Hämtade år"; + ff[1].value = years; + ff[1].cap = sizeof years; + ff[1].kind = TUI_F_TEXT; + ff[2].label = "Senast hämtad"; + ff[2].value = fetched; + ff[2].cap = sizeof fetched; + ff[2].kind = TUI_F_TEXT; + ff[3].label = "Källa"; + ff[3].value = url; + ff[3].cap = sizeof url; + ff[3].kind = TUI_F_TEXT; + struct tui_form_action acts[3]; + acts[0].label = status; + acts[0].enabled = -1; + acts[0].disabled_reason = NULL; + acts[1].label = "Hämta från Skatteverket"; + acts[1].enabled = owner ? 1 : 0; + acts[1].disabled_reason = reason; + acts[2].label = "Importera tabellfil (offline)…"; + acts[2].enabled = owner ? 1 : 0; + acts[2].disabled_reason = reason; + int r = tui_form_run_actions( + "Skattetabeller", ff, 4, 0, acts, 3, + "upp/ned/Tab = flytta Enter = utför F5 = uppdatera" + " Esc/q = tillbaka ^C = avsluta", + &sel); + if (r == TUI_FORM_ACTION + 1) + tax_tables_fetch(a, current_year); + else if (r == TUI_FORM_ACTION + 2) + tax_tables_import(a, current_year); + else if (r == TUI_FORM_BACK) + return; + } +} + +/* ------------------------------------------------------------------ */ +/* Lönekörningar */ +/* ------------------------------------------------------------------ */ + +struct payroll_run { + struct app *a; + int64_t id; + char period[16]; + char pay_date[16]; + char status[16]; + int64_t gross_ore; + int64_t tax_ore; + int64_t avgifter_ore; + int64_t net_ore; + int lines; +}; + +static void payroll_run_defaults(struct app *a, char *period, size_t pn, + char *pay_date, size_t dn) +{ + char today[16]; + payroll_today(today, sizeof today); + if (a->fy_start[0] && a->fy_end[0] && + strcmp(today, a->fy_start) >= 0 && strcmp(today, a->fy_end) <= 0) { + snprintf(period, pn, "%.7s", today); + snprintf(pay_date, dn, "%s", today); + return; + } + if (a->fy_start[0] && strlen(a->fy_start) >= 7) { + snprintf(period, pn, "%.7s", a->fy_start); + snprintf(pay_date, dn, "%s", a->fy_start); + return; + } + snprintf(period, pn, "%.7s", today); + snprintf(pay_date, dn, "%s", today); +} + +static int payroll_run_load(struct payroll_run *r) +{ + char args[64]; + snprintf(args, sizeof args, "{\"id\":%lld}", (long long)r->id); + char *resp = + client_rpc(&r->a->conn, "payroll.run_get", r->a->session, r->a->org, + args); + if (!resp || !client_ok(resp)) { + show_error("Lönekörning", resp); + free(resp); + return -1; + } + payroll_copy(r->period, sizeof r->period, resp, "result.period"); + payroll_copy(r->pay_date, sizeof r->pay_date, resp, "result.pay_date"); + payroll_copy(r->status, sizeof r->status, resp, "result.status"); + r->gross_ore = jint_val(resp, "result.gross_ore", 0); + r->tax_ore = jint_val(resp, "result.tax_ore", 0); + r->avgifter_ore = jint_val(resp, "result.avgifter_ore", 0); + r->net_ore = jint_val(resp, "result.net_ore", 0); + r->lines = (int)jarr_size(resp, "result.lines"); + free(resp); + return 0; +} + +static void payroll_fmt_lines(struct buf *t, yyjson_val *items) +{ + buf_line(t, "%-22s %-6s %13s %13s %13s %13s\n", "Namn", "Tabell", + "Brutto", "Skatt", "Avgifter", "Netto"); + size_t n = yyjson_arr_size(items); + for (size_t i = 0; i < n; i++) { + yyjson_val *it = yyjson_arr_get(items, i); + char name[224], tab[16], g[32], tx[32], av[32], net[32]; + snprintf(name, sizeof name, "%s", vstr(it, "name")); + tui_pad_field(name, sizeof name, 22); + snprintf(tab, sizeof tab, "%lld/%lld", + (long long)vint(it, "tax_table"), + (long long)vint(it, "tax_column")); + tui_pad_field(tab, sizeof tab, 6); + tui_amt_col(g, sizeof g, 13, vint(it, "gross_ore")); + tui_amt_col(tx, sizeof tx, 13, vint(it, "tax_ore")); + tui_amt_col(av, sizeof av, 13, vint(it, "avgifter_ore")); + tui_amt_col(net, sizeof net, 13, vint(it, "net_ore")); + buf_line(t, "%s %s %s %s %s %s\n", name, tab, g, tx, av, net); + } +} + +static void payroll_fmt_total_line(struct buf *t, int64_t gross, int64_t tax, + int64_t avgifter, int64_t net) +{ + char name[32] = "Summa", g[32], tx[32], av[32], nb[32]; + tui_pad_field(name, sizeof name, 22); + tui_amt_col(g, sizeof g, 13, gross); + tui_amt_col(tx, sizeof tx, 13, tax); + tui_amt_col(av, sizeof av, 13, avgifter); + tui_amt_col(nb, sizeof nb, 13, net); + buf_rule(t, 80); + buf_line(t, MK_DIM "%s %-6s %s %s %s %s\n", name, "", g, tx, av, nb); +} + +static void payroll_fmt_totals(struct buf *t, yyjson_val *totals) +{ + if (!totals) + return; + payroll_fmt_total_line(t, vint(totals, "gross_ore"), + vint(totals, "tax_ore"), + vint(totals, "avgifter_ore"), + vint(totals, "net_ore")); +} + +static void payroll_run_preview(struct payroll_run *r) +{ + if (!payroll_period_valid(r->period)) { + tui_message("Lönekörning", "Perioden måste vara ÅÅÅÅ-MM."); + return; + } + char *args = NULL; + const char *cmd; + if (r->id > 0) { + char idargs[64]; + snprintf(idargs, sizeof idargs, "{\"id\":%lld}", (long long)r->id); + args = xstrdup(idargs); + cmd = "payroll.run_get"; + } else { + 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, "period", r->period); + args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + cmd = "payroll.run_preview"; + } + if (!args) + return; + char *resp = + client_rpc(&r->a->conn, cmd, r->a->session, r->a->org, args); + free(args); + if (!resp || !client_ok(resp)) { + show_error("Lönekörning", resp); + free(resp); + return; + } + yyjson_doc *pd = parse(resp); + yyjson_val *res = pd ? jget(yyjson_doc_get_root(pd), "result") : NULL; + struct buf t; + buf_init(&t); + if (!res) { + buf_line(&t, "%s\n", resp); + } else { + if (r->id > 0) { + buf_line(&t, MK_HEAD "Lönekörning %s — %s\n", r->period, + payroll_status_sv(r->status)); + buf_line(&t, MK_DIM "Betalningsdatum %s\n", r->pay_date); + } else { + buf_line(&t, MK_HEAD + "Lönekörning %s — förhandsvisning (tabellår %lld)\n", + r->period, (long long)vint(res, "tax_year")); + } + buf_line(&t, "\n"); + payroll_fmt_lines(&t, yyjson_obj_get(res, "items")); + if (r->id > 0) + payroll_fmt_total_line(&t, r->gross_ore, r->tax_ore, + r->avgifter_ore, r->net_ore); + else + payroll_fmt_totals(&t, yyjson_obj_get(res, "totals")); + yyjson_val *accts = yyjson_obj_get(res, "accounts"); + if (accts && yyjson_is_obj(accts)) { + int64_t rate = vint(res, "avgift_rate_bp"); + buf_line(&t, "\n" MK_DIM + "Konton: skatt %s, avgifter %s (%.2f %%), avräkning %s," + " netto %s, skattekonto %s.\n", + vstr(accts, "tax"), vstr(accts, "avgift"), + (double)rate / 100.0, vstr(accts, "liability"), + vstr(accts, "net"), vstr(accts, "payment")); + } + } + buf_append(&t, "", 1); + char title[64]; + snprintf(title, sizeof title, "Lönekörning %s", r->period); + tui_pager(title, (const char *)t.p, NULL); + buf_free(&t); + yyjson_doc_free(pd); + free(resp); +} + +static void payroll_run_post(struct payroll_run *r) +{ + if (!payroll_period_valid(r->period)) { + tui_message("Lönekörning", "Perioden måste vara ÅÅÅÅ-MM."); + return; + } + if (!util_parse_iso_date(r->pay_date)) { + tui_message("Lönekörning", "Betalningsdatum måste vara ÅÅÅÅ-MM-DD."); + return; + } + if (!tui_confirm("Lönekörning", + "Bokför lönekörningen %s med betaldatum %s?", + r->period, r->pay_date)) + return; + 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, "period", r->period); + yyjson_mut_obj_add_strcpy(d, o, "pay_date", r->pay_date); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + if (!args) + return; + char *resp = client_rpc(&r->a->conn, "payroll.run_post", r->a->session, + r->a->org, args); + free(args); + if (!resp || !client_ok(resp)) { + show_error("Kunde inte bokföra lönekörningen", resp); + free(resp); + return; + } + r->id = jint_val(resp, "result.id", 0); + char ver[32]; + payroll_voucher_ver(r->a, jint_val(resp, "result.voucher_id", 0), ver, + sizeof ver); + free(resp); + char msg[96]; + if (ver[0]) + snprintf(msg, sizeof msg, "Lönekörningen är bokförd (%s).", ver); + else + snprintf(msg, sizeof msg, "Lönekörningen är bokförd."); + tui_message("Lönekörning", "%s", msg); +} + +static void payroll_run_payslip(struct payroll_run *r) +{ + char idargs[64]; + snprintf(idargs, sizeof idargs, "{\"id\":%lld}", (long long)r->id); + char *resp = client_rpc(&r->a->conn, "payroll.run_get", r->a->session, + r->a->org, idargs); + if (!resp || !client_ok(resp)) { + show_error("Lönebesked", resp); + free(resp); + return; + } + yyjson_doc *d = parse(resp); + yyjson_val *lines = + d ? jget(yyjson_doc_get_root(d), "result.lines") : NULL; + size_t n = yyjson_arr_size(lines); + int64_t employee_id = 0; + if (n == 1) { + employee_id = vint(yyjson_arr_get(lines, 0), "employee_id"); + } else if (n > 1) { + char **names = xcalloc(n, sizeof(char *)); + int64_t *ids = xcalloc(n, sizeof(int64_t)); + for (size_t i = 0; i < n; i++) { + yyjson_val *it = yyjson_arr_get(lines, i); + names[i] = xstrdup(vstr(it, "name")); + ids[i] = vint(it, "employee_id"); + } + int sel = tui_choice_prompt("Välj anställd: ", + (const char *const *)names, (int)n, 0); + if (sel >= 0) + employee_id = ids[sel]; + for (size_t i = 0; i < n; i++) + free(names[i]); + free(names); + free(ids); + } + free(resp); + yyjson_doc_free(d); + if (n == 0) { + tui_message("Lönebesked", "Körningen har inga anställda."); + return; + } + if (employee_id <= 0) + return; + yyjson_mut_doc *ad = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(ad); + yyjson_mut_doc_set_root(ad, o); + yyjson_mut_obj_add_int(ad, o, "run_id", r->id); + yyjson_mut_obj_add_int(ad, o, "employee_id", employee_id); + char *args = yyjson_mut_write(ad, 0, NULL); + yyjson_mut_doc_free(ad); + if (!args) + return; + resp = client_rpc(&r->a->conn, "payroll.payslip", r->a->session, r->a->org, + args); + free(args); + if (!resp || !client_ok(resp)) { + show_error("Kunde inte hämta lönebeskedet", resp); + free(resp); + return; + } + char *b64 = jstr_dup(resp, "result.content_base64"); + char *fname = jstr_dup(resp, "result.filename"); + free(resp); + if (b64 && fname) + pdf_save_and_open(b64, fname, "Lönebesked"); + free(b64); + free(fname); +} + +static void payroll_run_agi(struct payroll_run *r) +{ + if (!payroll_is_owner(r->a)) { + tui_message("AGI-underlag", "Kräver ägarbehörighet."); + return; + } + 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, "period", r->period); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + char *resp = + client_rpc(&r->a->conn, "payroll.agi", r->a->session, r->a->org, args); + free(args); + if (!resp || !client_ok(resp)) { + show_error("AGI-underlag", resp); + free(resp); + return; + } + yyjson_doc *pd = parse(resp); + yyjson_val *res = pd ? jget(yyjson_doc_get_root(pd), "result") : NULL; + struct buf t; + buf_init(&t); + if (!res) { + buf_line(&t, "%s\n", resp); + } else { + buf_line(&t, MK_HEAD "Arbetsgivardeklaration (AGI) %s\n", + vstr(res, "period")); + buf_line(&t, MK_DIM "Betalningsdatum %s\n", vstr(res, "pay_date")); + buf_line(&t, "\n"); + buf_line(&t, "%-22s %-15s %-6s %13s %13s %13s %13s\n", "Namn", + "Personnummer", "Tabell", "Brutto", "Skatt", "Avgifter", + "Netto"); + yyjson_val *items = yyjson_obj_get(res, "items"); + size_t n = yyjson_arr_size(items); + for (size_t i = 0; i < n; i++) { + yyjson_val *it = yyjson_arr_get(items, i); + char name[224], pn[24], tab[16], g[32], tx[32], av[32], net[32]; + snprintf(name, sizeof name, "%s", vstr(it, "name")); + tui_pad_field(name, sizeof name, 22); + snprintf(pn, sizeof pn, "%s", vstr(it, "personal_no")); + tui_pad_field(pn, sizeof pn, 15); + snprintf(tab, sizeof tab, "%lld/%lld", + (long long)vint(it, "tax_table"), + (long long)vint(it, "tax_column")); + tui_pad_field(tab, sizeof tab, 6); + tui_amt_col(g, sizeof g, 13, vint(it, "gross_ore")); + tui_amt_col(tx, sizeof tx, 13, vint(it, "tax_ore")); + tui_amt_col(av, sizeof av, 13, vint(it, "avgifter_ore")); + tui_amt_col(net, sizeof net, 13, vint(it, "net_ore")); + buf_line(&t, "%s %s %s %s %s %s %s\n", name, pn, tab, g, tx, av, + net); + } + payroll_fmt_totals(&t, yyjson_obj_get(res, "totals")); + buf_line(&t, "\n" MK_DIM + "Underlaget fylls i manuellt på skatteverket.se.\n"); + } + buf_append(&t, "", 1); + tui_pager("AGI-underlag", (const char *)t.p, NULL); + buf_free(&t); + yyjson_doc_free(pd); + free(resp); +} + +static void payroll_run_pay_tax(struct payroll_run *r) +{ + int64_t total = r->tax_ore + r->avgifter_ore; + if (total <= 0) { + tui_message("Betala skatt & avgifter", "Inget att betala."); + return; + } + char tb[32], sb[32], ab[32]; + tui_kr_format(total, tb, sizeof tb); + tui_kr_format(r->tax_ore, sb, sizeof sb); + tui_kr_format(r->avgifter_ore, ab, sizeof ab); + if (!tui_confirm("Betala skatt & avgifter", + "Betala %s för %s (skatt %s + avgifter %s)?", tb, + r->period, sb, ab)) + return; + char date[16]; + payroll_today(date, sizeof date); + if (!tui_date_prompt_into(date, sizeof date, "Betalningsdatum: ", date)) + return; + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_int(d, o, "run_id", r->id); + yyjson_mut_obj_add_strcpy(d, o, "date", date); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + char *resp = client_rpc(&r->a->conn, "payroll.pay_tax", r->a->session, + r->a->org, args); + free(args); + if (!resp || !client_ok(resp)) { + show_error("Kunde inte betala", resp); + free(resp); + return; + } + char ver[32]; + payroll_voucher_ver(r->a, jint_val(resp, "result.payment_voucher_id", 0), + ver, sizeof ver); + free(resp); + if (ver[0]) + tui_message("Betala skatt & avgifter", "Betalningen bokförd (%s).", + ver); + else + tui_message("Betala skatt & avgifter", "Betalningen bokförd."); +} + +static int payroll_salary_count(struct app *a) +{ + char *resp = client_rpc(&a->conn, "employee.list", a->session, a->org, + "{\"active_only\":true}"); + if (!resp || !client_ok(resp)) { + free(resp); + return 0; + } + size_t n = jarr_size(resp, "result.items"); + int count = 0; + for (size_t i = 0; i < n; i++) { + char path[64]; + snprintf(path, sizeof path, "result.items.%zu.monthly_salary_ore", i); + if (jint_val(resp, path, 0) > 0) + count++; + } + free(resp); + return count; +} + +static int64_t payroll_run_screen(struct app *a, int64_t id) +{ + static int focus = 0; + struct payroll_run r; + memset(&r, 0, sizeof r); + r.a = a; + r.id = id; + payroll_run_defaults(a, r.period, sizeof r.period, r.pay_date, + sizeof r.pay_date); + int can_write = payroll_can_write(a); + int owner = payroll_is_owner(a); + for (;;) { + if (g_quit) + return r.id; + int posted = r.id > 0; + int paid = 0; + if (posted) { + if (payroll_run_load(&r) != 0) + return r.id; + paid = strcmp(r.status, "paid") == 0; + } + char status[160]; + if (!posted) + snprintf(status, sizeof status, "Status: inte bokförd"); + else if (paid) + snprintf(status, sizeof status, "Status: betald"); + else + snprintf(status, sizeof status, "Status: bokförd %s", r.pay_date); + char totals[192]; + if (posted) { + char g[32], tx[32], av[32], net[32]; + tui_kr_format(r.gross_ore, g, sizeof g); + tui_kr_format(r.tax_ore, tx, sizeof tx); + tui_kr_format(r.avgifter_ore, av, sizeof av); + tui_kr_format(r.net_ore, net, sizeof net); + snprintf(totals, sizeof totals, + "Brutto %s Skatt %s Avgifter %s Netto %s", g, tx, + av, net); + } else { + int count = payroll_salary_count(a); + snprintf(totals, sizeof totals, "%d anställd%s med månadslön", + count, count == 1 ? "" : "a"); + } + const char *post_reason = NULL; + if (!can_write) + post_reason = "du har inte behörighet"; + else if (posted) + post_reason = "körningen är redan bokförd"; + const char *payslip_reason = + posted ? NULL : "körningen är inte bokförd än"; + const char *agi_reason = NULL; + if (!owner) + agi_reason = "kräver ägarbehörighet"; + else if (!posted) + agi_reason = "körningen är inte bokförd än"; + const char *pay_reason = NULL; + if (!can_write) + pay_reason = "du har inte behörighet"; + else if (!posted) + pay_reason = "körningen är inte bokförd än"; + else if (paid) + pay_reason = "redan betald"; + else if (r.tax_ore + r.avgifter_ore <= 0) + pay_reason = "inget att betala"; + struct tui_form_action acts[7] = { + { status, -1, NULL }, + { totals, -1, NULL }, + { "Förhandsvisa (F5)", 1, NULL }, + { "Bokför körning (^Enter)", post_reason ? 0 : 1, post_reason }, + { "Lönebesked (PDF)", payslip_reason ? 0 : 1, payslip_reason }, + { "AGI-underlag", agi_reason ? 0 : 1, agi_reason }, + { "Betala skatt & avgifter", pay_reason ? 0 : 1, pay_reason }, + }; + struct tui_form_field ff[2]; + memset(ff, 0, sizeof ff); + ff[0].label = "Period (ÅÅÅÅ-MM)"; + ff[0].value = r.period; + ff[0].cap = sizeof r.period; + ff[0].kind = TUI_F_TEXT; + ff[1].label = "Betalningsdatum"; + ff[1].value = r.pay_date; + ff[1].cap = sizeof r.pay_date; + ff[1].kind = TUI_F_DATE; + char title[64]; + if (posted) + snprintf(title, sizeof title, "Lönekörning %s", r.period); + else + 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 = bokför" + " Esc/q = tillbaka ^C = avsluta"); + int ret = tui_form_run_actions(title, ff, 2, editable, acts, 7, NULL, + &focus); + if (ret == TUI_FORM_BACK) + return r.id; + if (ret == TUI_FORM_REFRESH || ret == TUI_FORM_ACTION + 2) + payroll_run_preview(&r); + else if ((ret == TUI_FORM_SUBMIT || ret == TUI_FORM_ACTION + 3) && + !posted) + payroll_run_post(&r); + else if (ret == TUI_FORM_ACTION + 4) + payroll_run_payslip(&r); + else if (ret == TUI_FORM_ACTION + 5) + payroll_run_agi(&r); + else if (ret == TUI_FORM_ACTION + 6) + payroll_run_pay_tax(&r); + } + return r.id; +} + +void payroll_screen(struct app *a) +{ + int64_t keep_id = a->payroll_sel; + for (;;) { + if (g_quit) + return; + char *sresp = client_rpc(&a->conn, "payroll.tax_tables_status", + a->session, a->org, "{}"); + int stale = 0; + int year = 0; + if (sresp && client_ok(sresp)) { + stale = jbool_val(sresp, "result.stale", 0); + year = (int)jint_val(sresp, "result.current_year", 0); + } + free(sresp); + char *resp = client_rpc(&a->conn, "payroll.run_list", a->session, + a->org, "{\"limit\":200}"); + if (!resp || !client_ok(resp)) { + show_error("Lönekörningar", resp); + free(resp); + return; + } + size_t n = jarr_size(resp, "result.items"); + char **lines = xcalloc(n + 1, sizeof(char *)); + int64_t *ids = xcalloc(n + 1, sizeof(int64_t)); + for (size_t i = 0; i < n; i++) { + char path[64]; + snprintf(path, sizeof path, "result.items.%zu.id", i); + ids[i] = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.period", i); + char *period = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.items.%zu.pay_date", i); + char *pay_date = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.items.%zu.status", i); + char *status = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.items.%zu.gross_ore", i); + int64_t gross = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.net_ore", i); + int64_t net = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.line_count", i); + int64_t lc = jint_val(resp, path, 0); + char sbuf[16], gbuf[32], nbuf[32], line[256]; + snprintf(sbuf, sizeof sbuf, "%s", + payroll_status_sv(status ? status : "posted")); + tui_pad_field(sbuf, sizeof sbuf, 7); + tui_amt_col(gbuf, sizeof gbuf, 13, gross); + tui_amt_col(nbuf, sizeof nbuf, 13, net); + snprintf(line, sizeof line, "%s %s %s %s %s %lld anst.", + period ? period : "", pay_date ? pay_date : "", sbuf, + gbuf, nbuf, (long long)lc); + lines[i] = xstrdup(line); + free(period); + free(pay_date); + free(status); + } + free(resp); + size_t start = 0; + for (size_t i = 0; i < n; i++) + if (ids[i] == keep_id) { + start = i; + break; + } + lines[n] = xstrdup("+ Ny lönekörning (^N)"); + char title[160]; + if (stale) + snprintf(title, sizeof title, + "Lönekörningar — skattetabeller för %d saknas (se" + " Skattetabeller)", + year); + else + snprintf(title, sizeof title, "Lönekörningar"); + int cur = (int)start; + int sel = tui_select_list(title, lines, (int)n + 1, cur, 1, &cur, 1, + NULL, 0); + if (cur >= 0 && (size_t)cur < n) + a->payroll_sel = ids[cur]; + if (sel == -1) { + for (size_t i = 0; i <= n; i++) + free(lines[i]); + free(lines); + free(ids); + return; + } + if (sel == -4 || (sel >= 0 && (size_t)sel == n)) { + if (payroll_salary_count(a) == 0) { + tui_message("Lönekörningar", + "Inga aktiva anställda med månadslön. Lägg upp" + " dem under Register → Anställda."); + } else { + int64_t rid = payroll_run_screen(a, 0); + if (rid > 0) { + keep_id = rid; + a->payroll_sel = rid; + } + } + for (size_t i = 0; i <= n; i++) + free(lines[i]); + free(lines); + free(ids); + continue; + } + if (sel >= 0 && (size_t)sel < n) { + int64_t rid = payroll_run_screen(a, ids[sel]); + if (rid > 0) { + keep_id = rid; + a->payroll_sel = rid; + } + } + for (size_t i = 0; i <= n; i++) + free(lines[i]); + free(lines); + free(ids); + } +} diff --git a/clients/screens_settings.c b/clients/screens_settings.c index 6440150..c6a46c5 100644 --- a/clients/screens_settings.c +++ b/clients/screens_settings.c @@ -193,22 +193,181 @@ static void board_screen(struct app *a) } } +struct setting_field { + const char *key; + const char *label; + const char *const *choices; + int nchoices; + int mask; +}; + +static void setting_save(struct app *a, const struct setting_field *f, + const char *value, const char *title) +{ + if (f->mask && !*value) + return; /* empty secret means keep the stored one */ + 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, "key", f->key); + yyjson_mut_obj_add_strcpy(d, o, "value", value); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + if (!args) + return; + 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) + snprintf(a->default_series, sizeof a->default_series, "%s", + value); + else if (strcmp(f->key, "attachment_dir") == 0) + snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", + value); + } else { + show_error(title, rr); + } + free(rr); +} + +static void settings_form(struct app *a, const char *title, + const struct setting_field *fields, int n, + int can_edit, int show_password_status, int *focus) +{ + for (;;) { + if (g_quit) + return; + char *resp = client_rpc(&a->conn, "settings.get", a->session, a->org, + "{}"); + if (!resp || !client_ok(resp)) { + show_error(title, resp); + free(resp); + return; + } + char *vals[8]; + int choice[8]; + struct tui_form_field ff[8]; + memset(ff, 0, sizeof ff); + for (int i = 0; i < n; i++) { + char path[64]; + vals[i] = xcalloc(512, 1); + snprintf(path, sizeof path, "result.%s", fields[i].key); + char *v = jstr_dup(resp, path); + snprintf(vals[i], 512, "%s", v ? v : ""); + free(v); + ff[i].label = fields[i].label; + ff[i].value = vals[i]; + ff[i].cap = 512; + ff[i].kind = TUI_F_TEXT; + ff[i].mask = fields[i].mask; + choice[i] = 0; + if (fields[i].choices) { + ff[i].kind = TUI_F_CHOICE; + ff[i].choices = fields[i].choices; + ff[i].nchoices = fields[i].nchoices; + ff[i].choice = &choice[i]; + for (int k = 0; k < fields[i].nchoices; k++) + if (strcmp(vals[i], fields[i].choices[k]) == 0) + choice[i] = k; + } + } + char pwstatus[192]; + struct tui_form_action acts[1]; + memset(acts, 0, sizeof acts); + if (show_password_status) { + int pw_set = jbool_val(resp, "result.smtp_password_set", 0); + snprintf(pwstatus, sizeof pwstatus, + "SMTP-lösenord (lämna tomt för oförändrat): %s", + pw_set ? "satt" : "inte satt"); + acts[0].label = pwstatus; + acts[0].enabled = -1; + } + free(resp); + 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) { + const char *val = vals[r]; + if (fields[r].choices) + val = fields[r].choices[choice[r]]; + setting_save(a, &fields[r], val, title); + } + for (int i = 0; i < n; i++) + free(vals[i]); + if (r == TUI_FORM_BACK) + return; + } +} + +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 }, +}; + +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 }, +}; + +static const struct setting_field SYSTEM_SETTINGS[] = { + { "attachment_dir", "Bilagornas mapp", NULL, 0, 0 }, +}; + +static int settings_can_write(struct app *a) +{ + return a->role[0] && (strcmp(a->role, "owner") == 0 || + strcmp(a->role, "bookkeeper") == 0); +} + +static void invoice_settings_screen(struct app *a) +{ + static int sel = 0; + settings_form(a, "Fakturauppgifter", INVOICE_SETTINGS, + (int)(sizeof INVOICE_SETTINGS / sizeof INVOICE_SETTINGS[0]), + settings_can_write(a), 0, &sel); +} + +static void smtp_settings_screen(struct app *a) +{ + static int sel = 0; + settings_form(a, "E-post (SMTP)", SMTP_SETTINGS, + (int)(sizeof SMTP_SETTINGS / sizeof SMTP_SETTINGS[0]), + settings_can_write(a), 1, &sel); +} + +void system_settings_screen(struct app *a) +{ + static int sel = 0; + settings_form(a, "Systeminställningar", SYSTEM_SETTINGS, + (int)(sizeof SYSTEM_SETTINGS / sizeof SYSTEM_SETTINGS[0]), + settings_can_write(a), 0, &sel); +} + void company_screen(struct app *a) { static int sel = 0; const int nf = (int)(sizeof COMPANY_FIELDS / sizeof COMPANY_FIELDS[0]); - const int ninv = 2; - static const char *const invkeys[2] = { "invoice_bankgiro", - "invoice_our_ref" }; - static const char *const invlabels[2] = { "Bankgiro", - "Vår referens (faktura)" }; int owner = a->role[0] && strcmp(a->role, "owner") == 0; + int writer = settings_can_write(a); + const char *settings_reason = writer ? NULL : "du har inte behörighet"; for (;;) { if (g_quit) return; char *resp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { - show_error("Företagsuppgifter", resp); + show_error("Bolaget", resp); free(resp); return; } @@ -233,26 +392,15 @@ void company_screen(struct app *a) ff[i].kind = TUI_F_TEXT; } free(resp); - char *sresp = - client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); - for (int i = 0; i < ninv; i++) { - char path[64]; - int idx = nf + i; - vals[idx] = xcalloc(512, 1); - snprintf(path, sizeof path, "result.%s", invkeys[i]); - char *v = sresp ? jstr_dup(sresp, path) : NULL; - snprintf(vals[idx], 512, "%s", v ? v : ""); - free(v); - ff[idx].label = invlabels[i]; - ff[idx].value = vals[idx]; - ff[idx].cap = 512; - ff[idx].kind = TUI_F_TEXT; - } - free(sresp); - ff[nf + ninv].label = "Styrelseledamöter"; - ff[nf + ninv].kind = TUI_F_ACTION; - int r = tui_form_run("Företagsuppgifter", ff, nf + ninv + 1, owner, - &sel); + struct tui_form_action acts[3] = { + { "Fakturauppgifter…", writer ? 1 : 0, settings_reason }, + { "E-post (SMTP)…", writer ? 1 : 0, settings_reason }, + { "Styrelseledamöter", 1, NULL }, + }; + tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför" + " F5 = uppdatera Esc/q = tillbaka ^C = avsluta"); + int r = tui_form_run_actions("Bolaget", ff, nf, owner, acts, 3, NULL, + &sel); if (r >= 0 && r < nf) { if (!company_field_valid(&COMPANY_FIELDS[r], vals[r])) { tui_message("Ogiltigt värde", "%s", COMPANY_FIELDS[r].label); @@ -275,150 +423,18 @@ void company_screen(struct app *a) show_error("Kunde inte spara", rr); free(rr); } - } else if (r >= nf && r < nf + ninv) { - 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, "key", invkeys[r - nf]); - yyjson_mut_obj_add_strcpy(d, o, "value", vals[r]); - char *args = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - char *rr = client_rpc(&a->conn, "settings.set", a->session, - a->org, args); - free(args); - if (!rr || !client_ok(rr)) - show_error("Kunde inte spara", rr); - free(rr); } - if (r == nf + ninv) - board_screen(a); - for (int i = 0; i < nf + ninv; i++) + for (int i = 0; i < nf; i++) free(vals[i]); - if (r == TUI_FORM_BACK) + if (r == TUI_FORM_ACTION + 0) + invoice_settings_screen(a); + else if (r == TUI_FORM_ACTION + 1) + smtp_settings_screen(a); + else if (r == TUI_FORM_ACTION + 2) + board_screen(a); + else if (r == TUI_FORM_BACK) return; } } -static const char *const SETTING_KEYS[] = { - "default_series", - "attachment_dir", - "invoice_receivable_account", - "invoice_revenue_account", - "smtp_host", - "smtp_port", - "smtp_user", - "smtp_from", - "smtp_reply_to", - "smtp_security", - "smtp_password", -}; -static const char *const SETTING_LABELS[] = { - "Standardserie", - "Bilagornas mapp", - "Fordringskonto", - "Intäktskonto", - "SMTP-server", - "SMTP-port", - "SMTP-användare", - "SMTP-avsändare", - "SMTP-svarsadress", - "SMTP-säkerhet", - "SMTP-lösenord", -}; -static const char *const SECURITY_CHOICES[] = { "starttls", "tls", "plain" }; -void settings_screen(struct app *a) -{ - static int sel = 0; - const int nset = (int)(sizeof SETTING_KEYS / sizeof SETTING_KEYS[0]); - for (;;) { - if (g_quit) - return; - char *resp = - client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); - if (!resp || !client_ok(resp)) { - show_error("Inställningar", resp); - free(resp); - return; - } - int pw_set = jbool_val(resp, "result.smtp_password_set", 0); - char pwstatus[192]; - snprintf(pwstatus, sizeof pwstatus, - "SMTP-lösenord (lämna tomt för oförändrat): %s", - pw_set ? "satt" : "inte satt"); - char *vals[16]; - struct tui_form_field ff[16]; - memset(ff, 0, sizeof ff); - int sec_choice = 0; - for (int i = 0; i < nset; i++) { - char path[64], *v; - vals[i] = xcalloc(512, 1); - snprintf(path, sizeof path, "result.%s", SETTING_KEYS[i]); - v = jstr_dup(resp, path); - snprintf(vals[i], 512, "%s", v ? v : ""); - free(v); - const char *key = SETTING_KEYS[i]; - ff[i].label = SETTING_LABELS[i]; - ff[i].value = vals[i]; - ff[i].cap = 512; - ff[i].kind = TUI_F_TEXT; - if (strcmp(key, "smtp_security") == 0) { - ff[i].kind = TUI_F_CHOICE; - ff[i].choices = SECURITY_CHOICES; - ff[i].nchoices = 3; - ff[i].choice = &sec_choice; - for (int k = 0; k < 3; k++) - if (strcmp(vals[i], SECURITY_CHOICES[k]) == 0) - sec_choice = k; - } else if (strcmp(key, "smtp_password") == 0) { - ff[i].mask = 1; - } - } - free(resp); - struct tui_form_action acts[1]; - memset(acts, 0, sizeof acts); - acts[0].label = pwstatus; - acts[0].enabled = -1; /* dim status row, not selectable */ - int r = tui_form_run_actions( - "Inställningar", ff, nset, 1, acts, 1, - "upp/ned/Home/End Enter = ändra F5 = uppdatera" - " Esc/q = tillbaka ^C = avsluta", - &sel); - if (r >= 0 && r < nset) { - const char *key = SETTING_KEYS[r]; - const char *val = vals[r]; - if (strcmp(key, "smtp_security") == 0) - val = SECURITY_CHOICES[sec_choice]; - if (strcmp(key, "smtp_password") == 0 && !*vals[r]) { - /* empty means "keep the stored secret" */ - } else { - 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, "key", key); - yyjson_mut_obj_add_strcpy(d, o, "value", val); - char *args = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - char *rr = client_rpc(&a->conn, "settings.set", a->session, - a->org, args); - free(args); - if (rr && client_ok(rr)) { - snprintf(vals[r], 512, "%s", val); - if (strcmp(key, "default_series") == 0) - snprintf(a->default_series, - sizeof a->default_series, "%s", val); - else if (strcmp(key, "attachment_dir") == 0) - snprintf(a->attachment_dir, - sizeof a->attachment_dir, "%s", val); - } else { - show_error("Kunde inte spara inställningen", rr); - } - free(rr); - } - } - for (int i = 0; i < nset; i++) - free(vals[i]); - if (r == TUI_FORM_BACK) - return; - } -} diff --git a/clients/ui.c b/clients/ui.c index ff573c9..a95ce44 100644 --- a/clients/ui.c +++ b/clients/ui.c @@ -442,6 +442,91 @@ static int bytes_look_text(const unsigned char *p, size_t n) return 1; } +/* Directory for generated PDFs. */ +static void pdf_cache_dir(char *buf, size_t n) +{ + const char *cache = getenv("XDG_CACHE_HOME"); + if (cache && *cache) { + snprintf(buf, n, "%s/bokf", cache); + return; + } + snprintf(buf, n, "/tmp"); +} + +/* Keep a user-supplied name from escaping the directory. */ +static void safe_fname(const char *in, char *out, size_t n) +{ + size_t o = 0; + for (const char *p = in ? in : ""; *p && o + 1 < n; p++) { + unsigned char c = (unsigned char)*p; + if (c < 32 || c == '/') + c = '-'; + out[o++] = (char)c; + } + 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; + otherwise (or on fork failure) show where it was saved. */ +static void open_pdf(const char *path, const char *title) +{ + const char *display = getenv("DISPLAY"); + const char *wayland = getenv("WAYLAND_DISPLAY"); + if ((!display || !*display) && (!wayland || !*wayland)) { + tui_message(title, "Sparad: %s", path); + return; + } + pid_t pid = fork(); + if (pid == 0) { + pid_t gc = fork(); + if (gc == 0) { + setsid(); + execlp("xdg-open", "xdg-open", path, (char *)NULL); + _exit(127); + } + _exit(gc < 0 ? 1 : 0); + } + if (pid > 0) + waitpid(pid, NULL, 0); + else + tui_message(title, "Sparad: %s", path); +} + +void pdf_save_and_open(const char *b64, const char *filename, + const char *title) +{ + char dir[512], path[700], safe[600]; + pdf_cache_dir(dir, sizeof dir); + safe_fname(filename, safe, sizeof safe); + if (snprintf(path, sizeof path, "%s/%s", dir, safe) >= (int)sizeof path) { + tui_message(title, "Sökvägen blir för lång."); + return; + } + config_mkdirs(path); + if (write_pdf_b64(b64, path) != 0) { + tui_message(title, "Kunde inte spara %s", path); + return; + } + open_pdf(path, title); +} + /* Fetch one attachment, save it to a prompted path and verify its hash. Text attachments are shown inline after saving. */ void attachment_download(struct app *a, int64_t att_id) diff --git a/clients/ui.h b/clients/ui.h index d10f680..2b93106 100644 --- a/clients/ui.h +++ b/clients/ui.h @@ -39,6 +39,8 @@ struct app { int64_t voucher_sel; /* last selected voucher id in the list view */ int64_t invoice_sel; /* last selected invoice id in the list view */ int64_t customer_sel; /* last selected customer id in the list view */ + int64_t employee_sel; /* last selected employee id in the list view */ + int64_t payroll_sel; /* last selected payroll run id in the list view */ char default_series[16]; char attachment_dir[256]; long max_attachment_bytes; @@ -77,6 +79,8 @@ 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); int64_t pick_voucher(struct app *a); +void pdf_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, ...) @@ -125,10 +129,13 @@ void bank_screen(struct app *a); void audit_screen(struct app *a); void templates_screen(struct app *a); void ib_screen(struct app *a); -void settings_screen(struct app *a); void company_screen(struct app *a); +void system_settings_screen(struct app *a); void invoices_screen(struct app *a); void customers_screen(struct app *a); void bokslut_screen(struct app *a); +void employees_screen(struct app *a); +void payroll_screen(struct app *a); +void payroll_tax_tables_screen(struct app *a); #endif -- cgit v1.3