3012 lines
168 KiB
HTML
3012 lines
168 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="sv">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>LandveX Finance</title>
|
|||
|
|
<style>
|
|||
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|||
|
|
:root{
|
|||
|
|
--bg:#F7F6F3;--navy:#001E50;--amber:#C8763A;--card:#fff;--border:#E5E0D8;
|
|||
|
|
--muted:#6B6B70;--green:#27AE60;--red:#E53E3E;--blue:#2563EB;
|
|||
|
|
--navy-05:#001E500A;--navy-10:#001E5012;--amber-bg:#C8763A14;
|
|||
|
|
--shadow:0 1px 3px rgba(0,30,80,.08),0 4px 12px rgba(0,30,80,.06);
|
|||
|
|
--r:10px;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|||
|
|
}
|
|||
|
|
html{font-family:var(--font);font-size:14px;background:var(--bg);color:var(--navy)}
|
|||
|
|
body{min-height:100vh}
|
|||
|
|
button{font-family:var(--font);cursor:pointer}
|
|||
|
|
input,select{font-family:var(--font)}
|
|||
|
|
|
|||
|
|
/* ── Topbar ── */
|
|||
|
|
/* ── TOPBAR — tvårads layout ── */
|
|||
|
|
#topbar{
|
|||
|
|
position:sticky;top:0;z-index:100;background:var(--navy);color:#fff;
|
|||
|
|
display:flex;flex-direction:column;
|
|||
|
|
box-shadow:0 2px 10px rgba(0,0,0,.3);
|
|||
|
|
}
|
|||
|
|
#topbar-top{
|
|||
|
|
display:flex;align-items:center;gap:10px;padding:0 20px;height:52px;
|
|||
|
|
border-bottom:1px solid rgba(255,255,255,.07);
|
|||
|
|
}
|
|||
|
|
#topbar-tabs{
|
|||
|
|
display:flex;align-items:stretch;background:rgba(0,0,0,.18);
|
|||
|
|
padding:0 8px;
|
|||
|
|
}
|
|||
|
|
.brand{display:flex;align-items:center;gap:10px;flex-shrink:0}
|
|||
|
|
.brand-logo{width:30px;height:30px;border-radius:50%;background:var(--amber);
|
|||
|
|
display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:800}
|
|||
|
|
.brand-name{font-size:.95rem;font-weight:700;white-space:nowrap}
|
|||
|
|
.brand-co{font-size:.8rem;color:rgba(255,255,255,.5);margin-left:2px;white-space:nowrap}
|
|||
|
|
|
|||
|
|
/* Tabbar — full bredd, understreck-stil, ingen scroll */
|
|||
|
|
#tab-nav{display:flex;align-items:stretch;flex:1;gap:0}
|
|||
|
|
.tab-btn{
|
|||
|
|
background:transparent;border:none;border-bottom:3px solid transparent;
|
|||
|
|
color:rgba(255,255,255,.55);
|
|||
|
|
padding:10px 18px;font-size:.83rem;font-weight:600;cursor:pointer;
|
|||
|
|
white-space:nowrap;transition:color .15s,border-color .15s;
|
|||
|
|
display:flex;align-items:center;gap:5px;
|
|||
|
|
}
|
|||
|
|
.tab-btn:hover{color:rgba(255,255,255,.9);border-bottom-color:rgba(255,255,255,.3)}
|
|||
|
|
.tab-btn.active{color:#fff;border-bottom:3px solid var(--amber);font-weight:700}
|
|||
|
|
|
|||
|
|
/* period */
|
|||
|
|
.period-bar{display:flex;align-items:center;gap:5px;flex-shrink:0}
|
|||
|
|
.pmode-group{display:flex;background:rgba(255,255,255,.1);border-radius:7px;padding:2px;gap:1px}
|
|||
|
|
.pmode-btn{background:transparent;border:none;color:rgba(255,255,255,.6);
|
|||
|
|
padding:4px 10px;border-radius:5px;font-size:.76rem;font-weight:600;cursor:pointer;white-space:nowrap}
|
|||
|
|
.pmode-btn.active{background:var(--amber);color:#fff}
|
|||
|
|
.psel{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);
|
|||
|
|
color:#fff;border-radius:7px;padding:5px 9px;font-size:.8rem;cursor:pointer;outline:none}
|
|||
|
|
.psel option{background:#001E50}
|
|||
|
|
.pcustom{display:none;align-items:center;gap:4px}
|
|||
|
|
.pcustom-in{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);
|
|||
|
|
color:#fff;border-radius:7px;padding:4px 7px;font-size:.78rem;outline:none;width:108px}
|
|||
|
|
.pcustom-in::-webkit-calendar-picker-indicator{filter:invert(1) brightness(.7)}
|
|||
|
|
|
|||
|
|
/* status + actions */
|
|||
|
|
.topbar-right{display:flex;align-items:center;gap:8px;margin-left:4px;flex-shrink:0}
|
|||
|
|
#data-status{display:flex;align-items:center;gap:5px;font-size:.74rem;color:rgba(255,255,255,.6);min-width:44px;justify-content:flex-end}
|
|||
|
|
.sdot{width:7px;height:7px;border-radius:50%;background:var(--muted)}
|
|||
|
|
.sdot.live{background:var(--green);box-shadow:0 0 0 2px rgba(39,174,96,.3)}
|
|||
|
|
.sdot.demo{background:var(--amber)}
|
|||
|
|
.sdot.err{background:var(--red)}
|
|||
|
|
.btn-top{background:var(--amber);border:none;color:#fff;border-radius:7px;
|
|||
|
|
padding:7px 16px;font-size:.82rem;font-weight:600;cursor:pointer;white-space:nowrap}
|
|||
|
|
.btn-top:hover{background:#b0612e}
|
|||
|
|
|
|||
|
|
/* ── Main ── */
|
|||
|
|
#main{max-width:1320px;margin:0 auto;padding:20px 18px 0}
|
|||
|
|
.tab-sec{display:none}.tab-sec.active{display:block}
|
|||
|
|
|
|||
|
|
/* ── Cards ── */
|
|||
|
|
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--r);
|
|||
|
|
box-shadow:var(--shadow);margin-bottom:14px;overflow:hidden}
|
|||
|
|
.card-hdr{display:flex;align-items:center;justify-content:space-between;
|
|||
|
|
padding:12px 16px;border-bottom:1px solid var(--border);background:var(--navy-05)}
|
|||
|
|
.card-title{font-size:.88rem;font-weight:700}
|
|||
|
|
.card-body{padding:14px 16px}
|
|||
|
|
.badge{font-size:.7rem;font-weight:700;padding:2px 8px;border-radius:5px;background:var(--navy-10)}
|
|||
|
|
|
|||
|
|
/* ── KPI row ── */
|
|||
|
|
.kpi-row{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:14px}
|
|||
|
|
.kpi[onclick]:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.12);transition:transform .15s,box-shadow .15s}
|
|||
|
|
.kpi[onclick]{transition:transform .15s,box-shadow .15s}
|
|||
|
|
.kpi{background:var(--card);border:1px solid var(--border);border-radius:var(--r);
|
|||
|
|
padding:16px 18px;box-shadow:var(--shadow);position:relative;overflow:hidden}
|
|||
|
|
.kpi::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:3px 0 0 3px}
|
|||
|
|
.kpi.green::before{background:var(--green)}.kpi.red::before{background:var(--red)}
|
|||
|
|
.kpi.amber::before{background:var(--amber)}.kpi.navy::before{background:var(--navy)}
|
|||
|
|
.kpi.blue::before{background:var(--blue)}
|
|||
|
|
.kpi-icon{font-size:1.1rem;margin-bottom:6px}
|
|||
|
|
.kpi-val{font-size:1.35rem;font-weight:800;font-variant-numeric:tabular-nums;margin-bottom:3px}
|
|||
|
|
.kpi-val.green{color:var(--green)}.kpi-val.red{color:var(--red)}
|
|||
|
|
.kpi-val.amber{color:var(--amber)}.kpi-val.navy{color:var(--navy)}
|
|||
|
|
.kpi-lbl{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}
|
|||
|
|
|
|||
|
|
/* ── Two-col layout ── */
|
|||
|
|
.two-col{display:grid;grid-template-columns:1fr 340px;gap:14px}
|
|||
|
|
|
|||
|
|
/* ── P&L ── */
|
|||
|
|
.pl-sec-title{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.07em;
|
|||
|
|
color:var(--muted);margin-bottom:7px;padding-bottom:4px;border-bottom:1px solid var(--border)}
|
|||
|
|
.pl-row{display:flex;justify-content:space-between;align-items:center;
|
|||
|
|
padding:5px 4px;border-radius:5px;font-size:.84rem}
|
|||
|
|
.pl-row:hover{background:var(--navy-05)}
|
|||
|
|
.pl-acct{color:var(--muted)}
|
|||
|
|
.pl-code{font-size:.68rem;color:var(--muted);background:var(--navy-10);border-radius:3px;padding:1px 4px;margin-left:5px}
|
|||
|
|
.pl-amt{font-weight:600;font-variant-numeric:tabular-nums}
|
|||
|
|
.pl-total{display:flex;justify-content:space-between;padding:7px 8px;
|
|||
|
|
background:var(--navy-05);border-radius:6px;margin:6px 0}
|
|||
|
|
.pl-total-lbl{font-size:.84rem;font-weight:700}
|
|||
|
|
.pl-total-amt{font-size:.9rem;font-weight:800;font-variant-numeric:tabular-nums}
|
|||
|
|
.pl-result{display:flex;justify-content:space-between;align-items:center;
|
|||
|
|
padding:10px 14px;border-radius:8px;margin-top:10px}
|
|||
|
|
.pl-result.pos{background:rgba(39,174,96,.08);border:1px solid rgba(39,174,96,.2)}
|
|||
|
|
.pl-result.neg{background:rgba(229,62,62,.06);border:1px solid rgba(229,62,62,.2)}
|
|||
|
|
.pl-result-lbl{font-size:.85rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em}
|
|||
|
|
.pl-result-amt{font-size:1.2rem;font-weight:800;font-variant-numeric:tabular-nums}
|
|||
|
|
|
|||
|
|
/* ── Right col ── */
|
|||
|
|
.rc-actions{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
|
|||
|
|
.act-btn{display:flex;align-items:center;gap:5px;padding:7px 12px;border-radius:7px;
|
|||
|
|
font-size:.8rem;font-weight:600;border:1.5px solid var(--border);background:transparent;
|
|||
|
|
color:var(--navy);cursor:pointer;transition:all .15s;white-space:nowrap}
|
|||
|
|
.act-btn:hover{border-color:var(--amber);background:var(--amber-bg)}
|
|||
|
|
.act-btn.primary{background:var(--amber);border-color:var(--amber);color:#fff}
|
|||
|
|
.act-btn.primary:hover{background:#b0612e}
|
|||
|
|
|
|||
|
|
/* ── Journal widget ── */
|
|||
|
|
.jw-row{display:grid;grid-template-columns:52px 78px 1fr auto;
|
|||
|
|
gap:8px;align-items:center;padding:6px 4px;border-bottom:1px solid var(--border);cursor:pointer}
|
|||
|
|
.jw-row:last-child{border-bottom:none}
|
|||
|
|
.jw-row:hover{background:var(--navy-05);border-radius:6px;margin:0 -4px;padding:6px 8px}
|
|||
|
|
.jw-nr{font-size:.7rem;color:var(--muted);background:var(--navy-10);border-radius:4px;
|
|||
|
|
padding:2px 5px;text-align:center;font-variant-numeric:tabular-nums}
|
|||
|
|
.jw-date{font-size:.75rem;color:var(--muted);font-variant-numeric:tabular-nums}
|
|||
|
|
.jw-desc{font-size:.82rem;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;min-width:0}
|
|||
|
|
.jw-amt{font-size:.82rem;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|||
|
|
.tag{font-size:.66rem;font-weight:700;padding:1px 4px;border-radius:3px;
|
|||
|
|
background:var(--amber-bg);color:var(--amber);margin-left:3px;vertical-align:middle}
|
|||
|
|
.tag.bank{background:rgba(37,99,235,.1);color:var(--blue)}
|
|||
|
|
|
|||
|
|
/* ── Period badge ── */
|
|||
|
|
.pbadge{display:inline-flex;align-items:center;gap:4px;font-size:.75rem;font-weight:600;
|
|||
|
|
padding:3px 9px;border-radius:20px}
|
|||
|
|
.pbadge.open{background:rgba(39,174,96,.1);color:#1a8a45}
|
|||
|
|
.pbadge.review{background:rgba(200,118,58,.12);color:var(--amber)}
|
|||
|
|
.pbadge.closed{background:var(--navy-10);color:var(--muted)}
|
|||
|
|
|
|||
|
|
/* ── TRANSAKTIONER-FLIK ── */
|
|||
|
|
.tx-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap}
|
|||
|
|
.tx-search{flex:1;min-width:200px;border:1.5px solid var(--border);border-radius:8px;
|
|||
|
|
padding:8px 12px;font-size:.85rem;outline:none;background:#fff}
|
|||
|
|
.tx-search:focus{border-color:var(--amber)}
|
|||
|
|
.tx-filter-sel{border:1.5px solid var(--border);border-radius:8px;padding:7px 10px;
|
|||
|
|
font-size:.82rem;outline:none;background:#fff;cursor:pointer}
|
|||
|
|
.tx-filter-sel:focus{border-color:var(--amber)}
|
|||
|
|
.tx-table{width:100%;border-collapse:collapse}
|
|||
|
|
.tx-table th{text-align:left;font-size:.68rem;font-weight:700;text-transform:uppercase;
|
|||
|
|
letter-spacing:.06em;color:var(--muted);padding:7px 10px;border-bottom:2px solid var(--border);
|
|||
|
|
white-space:nowrap;background:var(--navy-05)}
|
|||
|
|
.tx-table td{padding:8px 10px;border-bottom:1px solid var(--border);font-size:.83rem;vertical-align:middle}
|
|||
|
|
.tx-table tr:hover td{background:var(--navy-05);cursor:pointer}
|
|||
|
|
.tx-nr{font-size:.72rem;color:var(--muted);background:var(--navy-10);border-radius:4px;
|
|||
|
|
padding:2px 5px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|||
|
|
.tx-amt{font-weight:600;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}
|
|||
|
|
.tx-empty{text-align:center;padding:40px;color:var(--muted)}
|
|||
|
|
.tx-pagination{display:flex;align-items:center;justify-content:space-between;
|
|||
|
|
padding:10px 0;margin-top:4px;font-size:.82rem;color:var(--muted)}
|
|||
|
|
.pg-btn{border:1.5px solid var(--border);background:#fff;border-radius:7px;
|
|||
|
|
padding:5px 12px;font-size:.8rem;cursor:pointer;font-weight:600}
|
|||
|
|
.pg-btn:hover:not(:disabled){border-color:var(--amber);color:var(--amber)}
|
|||
|
|
.pg-btn:disabled{opacity:.4;cursor:default}
|
|||
|
|
.src-bank{color:var(--blue);font-size:.7rem;font-weight:700}
|
|||
|
|
.src-manual{color:var(--muted);font-size:.7rem;font-weight:700}
|
|||
|
|
|
|||
|
|
/* ── TX MOBILKORT ── */
|
|||
|
|
.tx-cards{display:none}
|
|||
|
|
.tx-card{
|
|||
|
|
display:flex;align-items:center;gap:12px;
|
|||
|
|
padding:13px 14px;border-bottom:1px solid var(--border);
|
|||
|
|
cursor:pointer;transition:background .12s;
|
|||
|
|
-webkit-tap-highlight-color:transparent;
|
|||
|
|
}
|
|||
|
|
.tx-card:last-child{border-bottom:none}
|
|||
|
|
.tx-card:active{background:var(--navy-05)}
|
|||
|
|
.tx-card-left{
|
|||
|
|
flex-shrink:0;width:42px;height:42px;border-radius:10px;
|
|||
|
|
background:var(--navy-05);display:flex;flex-direction:column;
|
|||
|
|
align-items:center;justify-content:center;gap:1px;
|
|||
|
|
}
|
|||
|
|
.tx-card-nr{font-size:.62rem;font-weight:700;color:var(--muted);font-variant-numeric:tabular-nums}
|
|||
|
|
.tx-card-src-dot{width:6px;height:6px;border-radius:50%;background:var(--muted)}
|
|||
|
|
.tx-card-src-dot.bank{background:var(--blue)}
|
|||
|
|
.tx-card-body{flex:1;min-width:0}
|
|||
|
|
.tx-card-desc{
|
|||
|
|
font-size:.88rem;font-weight:600;color:var(--navy);
|
|||
|
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
|||
|
|
margin-bottom:3px;
|
|||
|
|
}
|
|||
|
|
.tx-card-meta{display:flex;align-items:center;gap:8px;font-size:.74rem;color:var(--muted)}
|
|||
|
|
.tx-card-accts{font-size:.72rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:160px}
|
|||
|
|
.tx-card-right{flex-shrink:0;text-align:right}
|
|||
|
|
.tx-card-amt{font-size:.9rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--navy);white-space:nowrap}
|
|||
|
|
.tx-card-status{font-size:.68rem;margin-top:2px}
|
|||
|
|
.tx-card-empty{padding:40px 16px;text-align:center;color:var(--muted)}
|
|||
|
|
|
|||
|
|
/* Mobilsök — stor, täcker hela bredden */
|
|||
|
|
@media(max-width:640px){
|
|||
|
|
.tx-toolbar{gap:6px}
|
|||
|
|
.tx-search{font-size:.92rem;padding:10px 14px}
|
|||
|
|
.tx-filter-row{display:flex;gap:6px}
|
|||
|
|
.tx-filter-sel{flex:1;font-size:.82rem;padding:9px 8px}
|
|||
|
|
/* Växla tabell <-> kort */
|
|||
|
|
.tx-table-wrap{display:none}
|
|||
|
|
.tx-cards{display:block}
|
|||
|
|
/* Paginering: större knappar */
|
|||
|
|
.pg-btn{padding:10px 20px;font-size:.85rem}
|
|||
|
|
.tx-pagination{gap:8px}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── FAKTUROR-FLIK ── */
|
|||
|
|
.inv-table{width:100%;border-collapse:collapse}
|
|||
|
|
.inv-table th{text-align:left;font-size:.68rem;font-weight:700;text-transform:uppercase;
|
|||
|
|
letter-spacing:.06em;color:var(--muted);padding:7px 10px;border-bottom:2px solid var(--border);
|
|||
|
|
background:var(--navy-05)}
|
|||
|
|
.inv-table td{padding:8px 10px;border-bottom:1px solid var(--border);font-size:.83rem}
|
|||
|
|
.inv-table tr:hover td{background:var(--navy-05);cursor:pointer}
|
|||
|
|
.inv-status{display:inline-flex;align-items:center;gap:3px;font-size:.7rem;font-weight:700;
|
|||
|
|
padding:2px 7px;border-radius:10px}
|
|||
|
|
.inv-status.paid{background:rgba(39,174,96,.1);color:#1a8a45}
|
|||
|
|
.inv-status.sent{background:rgba(200,118,58,.12);color:var(--amber)}
|
|||
|
|
.inv-status.overdue{background:rgba(229,62,62,.1);color:var(--red)}
|
|||
|
|
|
|||
|
|
/* ── MOMS ── */
|
|||
|
|
.vat-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:14px}
|
|||
|
|
.vat-card{background:var(--navy-05);border:1px solid var(--border);border-radius:8px;padding:12px 14px}
|
|||
|
|
.vat-ruta{font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:3px}
|
|||
|
|
.vat-lbl{font-size:.8rem;color:var(--muted);margin-bottom:5px}
|
|||
|
|
.vat-amt{font-size:1.1rem;font-weight:800;font-variant-numeric:tabular-nums}
|
|||
|
|
.vat-note{font-size:.68rem;color:var(--muted);margin-top:3px}
|
|||
|
|
.vat-total-row{display:flex;justify-content:space-between;align-items:center;
|
|||
|
|
padding:14px 16px;border-radius:8px;background:var(--navy);color:#fff;margin-bottom:10px}
|
|||
|
|
.vat-total-lbl{font-size:.88rem;font-weight:700}
|
|||
|
|
.vat-total-amt{font-size:1.3rem;font-weight:800;font-variant-numeric:tabular-nums;color:#ff9f7a}
|
|||
|
|
.vat-alert{padding:10px 14px;border-radius:8px;font-size:.82rem;font-weight:600;margin-bottom:12px}
|
|||
|
|
.vat-alert.warn{background:rgba(229,62,62,.08);border:1px solid rgba(229,62,62,.25);color:var(--red)}
|
|||
|
|
|
|||
|
|
/* ── PERIODSTÄNGNING ── */
|
|||
|
|
.wf-steps{display:flex;align-items:center;gap:3px;margin-bottom:14px;flex-wrap:wrap}
|
|||
|
|
.wf-step{padding:4px 10px;border-radius:5px;font-size:.75rem;font-weight:600;
|
|||
|
|
background:var(--navy-10);color:var(--muted)}
|
|||
|
|
.wf-step.active{background:var(--amber);color:#fff}
|
|||
|
|
.wf-step.done{background:rgba(39,174,96,.15);color:var(--green)}
|
|||
|
|
.wf-arrow{color:var(--muted);font-size:.85rem}
|
|||
|
|
.chk-list{list-style:none}
|
|||
|
|
.chk-item{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--border);font-size:.85rem}
|
|||
|
|
.chk-item:last-child{border-bottom:none}
|
|||
|
|
.chk-icon{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;
|
|||
|
|
justify-content:center;font-size:.78rem;flex-shrink:0}
|
|||
|
|
.chk-icon.ok{background:rgba(39,174,96,.15);color:var(--green)}
|
|||
|
|
.chk-icon.warn{background:rgba(229,62,62,.1);color:var(--red)}
|
|||
|
|
.chk-icon.pending{background:var(--navy-10);color:var(--muted)}
|
|||
|
|
.chk-val{font-size:.78rem;font-weight:600;color:var(--muted);margin-left:auto;font-variant-numeric:tabular-nums}
|
|||
|
|
|
|||
|
|
/* ── NY VERIFIKATION ── */
|
|||
|
|
.entry-toggle{width:100%;display:flex;align-items:center;justify-content:space-between;
|
|||
|
|
padding:13px 16px;background:transparent;border:none;cursor:pointer;text-align:left}
|
|||
|
|
.entry-toggle:hover{background:var(--navy-05)}
|
|||
|
|
.entry-body{display:none;padding:16px;border-top:1px solid var(--border)}
|
|||
|
|
.entry-body.open{display:block}
|
|||
|
|
.form-row{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:12px}
|
|||
|
|
.form-grp{display:flex;flex-direction:column;gap:4px}
|
|||
|
|
.form-lbl{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
|
|||
|
|
.form-in{border:1.5px solid var(--border);border-radius:7px;padding:8px 10px;
|
|||
|
|
font-size:.87rem;outline:none;background:#fff;transition:border-color .15s}
|
|||
|
|
.form-in:focus{border-color:var(--amber)}
|
|||
|
|
.lines-hdr{display:grid;grid-template-columns:1fr 110px 110px 32px;gap:6px;
|
|||
|
|
padding:0 0 5px;font-size:.68rem;font-weight:700;text-transform:uppercase;
|
|||
|
|
letter-spacing:.05em;color:var(--muted)}
|
|||
|
|
.e-line{display:grid;grid-template-columns:1fr 110px 110px 32px;gap:6px;margin-bottom:7px;align-items:start}
|
|||
|
|
.acc-wrap{position:relative}
|
|||
|
|
.acc-in{border:1.5px solid var(--border);border-radius:7px;padding:7px 9px;
|
|||
|
|
font-size:.84rem;width:100%;outline:none;background:#fff;transition:border-color .15s}
|
|||
|
|
.acc-in:focus{border-color:var(--amber)}
|
|||
|
|
.acc-drop{position:absolute;top:100%;left:0;right:0;z-index:50;background:#fff;
|
|||
|
|
border:1px solid var(--border);border-radius:7px;box-shadow:var(--shadow);
|
|||
|
|
max-height:190px;overflow-y:auto;display:none}
|
|||
|
|
.acc-drop.open{display:block}
|
|||
|
|
.acc-opt{padding:7px 9px;font-size:.83rem;cursor:pointer;display:flex;align-items:center;gap:7px}
|
|||
|
|
.acc-opt:hover,.acc-opt.focused{background:var(--navy-05)}
|
|||
|
|
.acc-code{font-size:.72rem;color:var(--muted);background:var(--navy-10);border-radius:3px;
|
|||
|
|
padding:1px 4px;flex-shrink:0;font-variant-numeric:tabular-nums}
|
|||
|
|
.num-in{border:1.5px solid var(--border);border-radius:7px;padding:7px 9px;
|
|||
|
|
font-size:.84rem;text-align:right;width:100%;outline:none;background:#fff;
|
|||
|
|
font-variant-numeric:tabular-nums;transition:border-color .15s}
|
|||
|
|
.num-in:focus{border-color:var(--amber)}
|
|||
|
|
.rm-btn{background:transparent;border:1.5px solid var(--border);border-radius:6px;
|
|||
|
|
color:var(--muted);font-size:.8rem;width:30px;height:34px;display:flex;
|
|||
|
|
align-items:center;justify-content:center;flex-shrink:0}
|
|||
|
|
.rm-btn:hover{border-color:var(--red);color:var(--red)}
|
|||
|
|
#add-line-btn{background:transparent;border:1.5px dashed var(--border);border-radius:7px;
|
|||
|
|
color:var(--muted);font-size:.82rem;padding:7px;width:100%;margin-bottom:12px}
|
|||
|
|
#add-line-btn:hover{border-color:var(--amber);color:var(--amber)}
|
|||
|
|
.bal-ind{display:flex;align-items:center;justify-content:space-between;
|
|||
|
|
padding:9px 12px;border-radius:7px;background:var(--navy-05);border:1.5px solid var(--border);margin-bottom:12px}
|
|||
|
|
.bal-ind.ok{background:rgba(39,174,96,.07);border-color:rgba(39,174,96,.3)}
|
|||
|
|
.bal-ind.bad{background:rgba(229,62,62,.05);border-color:rgba(229,62,62,.2)}
|
|||
|
|
.form-actions{display:flex;align-items:center;gap:8px}
|
|||
|
|
.btn-primary{background:var(--navy);color:#fff;border:none;border-radius:7px;
|
|||
|
|
padding:9px 18px;font-size:.87rem;font-weight:600}
|
|||
|
|
.btn-primary:hover{background:#002878}
|
|||
|
|
.btn-primary:disabled{opacity:.5;cursor:not-allowed}
|
|||
|
|
.btn-secondary{background:transparent;border:1.5px solid var(--border);color:var(--muted);
|
|||
|
|
border-radius:7px;padding:9px 18px;font-size:.87rem;font-weight:600}
|
|||
|
|
.btn-secondary:hover{border-color:var(--navy);color:var(--navy)}
|
|||
|
|
.btn-outline{border:1.5px solid var(--border);background:transparent;
|
|||
|
|
padding:7px 13px;font-size:.8rem;font-weight:600;color:var(--navy);border-radius:7px}
|
|||
|
|
.btn-outline:hover{border-color:var(--navy);background:var(--navy-05)}
|
|||
|
|
.feedback{font-size:.82rem;color:var(--muted);display:none}
|
|||
|
|
.feedback.ok{display:block;color:var(--green)}
|
|||
|
|
.feedback.err{display:block;color:var(--red)}
|
|||
|
|
|
|||
|
|
/* ── Modal ── */
|
|||
|
|
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:200;
|
|||
|
|
display:flex;align-items:center;justify-content:center;padding:16px}
|
|||
|
|
.modal-bg.hidden{display:none}
|
|||
|
|
.modal-box{background:#fff;border-radius:var(--r);box-shadow:0 8px 40px rgba(0,30,80,.2);
|
|||
|
|
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;overflow:hidden}
|
|||
|
|
.modal-hdr{display:flex;align-items:center;justify-content:space-between;
|
|||
|
|
padding:16px 20px;border-bottom:1px solid var(--border);background:var(--navy-05);flex-shrink:0}
|
|||
|
|
.modal-title{font-size:.95rem;font-weight:700}
|
|||
|
|
.modal-close{background:none;border:none;font-size:1.1rem;color:var(--muted);cursor:pointer;line-height:1;padding:2px 6px}
|
|||
|
|
.modal-close:hover{color:var(--navy)}
|
|||
|
|
.modal-body{padding:20px;overflow-y:auto;flex:1}
|
|||
|
|
.modal-footer{padding:12px 20px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;flex-shrink:0}
|
|||
|
|
/* entry detail table */
|
|||
|
|
.det-table{width:100%;border-collapse:collapse;font-size:.83rem;margin-top:10px}
|
|||
|
|
.det-table th{text-align:left;font-size:.68rem;font-weight:700;text-transform:uppercase;
|
|||
|
|
letter-spacing:.06em;color:var(--muted);padding:5px 8px;border-bottom:2px solid var(--border)}
|
|||
|
|
.det-table td{padding:7px 8px;border-bottom:1px solid var(--border)}
|
|||
|
|
.det-total{background:var(--navy-05);font-weight:700}
|
|||
|
|
.det-meta{margin-top:12px;padding:10px 12px;background:var(--navy-05);border-radius:7px;font-size:.78rem;color:var(--muted)}
|
|||
|
|
.det-meta strong{color:var(--navy)}
|
|||
|
|
|
|||
|
|
/* ── Footer ── */
|
|||
|
|
.fc-bar-col{display:flex;flex-direction:column;align-items:center;flex:1;min-width:0}
|
|||
|
|
.fc-bar{border-radius:4px 4px 0 0;width:100%;min-height:3px}
|
|||
|
|
.fc-bar.pos{background:var(--green)}.fc-bar.neg{background:var(--red)}.fc-bar.warn{background:var(--amber)}
|
|||
|
|
.fc-bv{font-size:.62rem;color:var(--muted);margin-top:2px;text-align:center;font-variant-numeric:tabular-nums}
|
|||
|
|
.act-row{display:flex;gap:8px;align-items:flex-start;padding:7px 0;border-bottom:1px solid var(--border);font-size:.81rem}
|
|||
|
|
.act-row:last-child{border-bottom:none}
|
|||
|
|
.act-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:4px}
|
|||
|
|
.act-dot.r{background:var(--red)}.act-dot.a{background:var(--amber)}.act-dot.g{background:var(--green)}
|
|||
|
|
.rec{padding:8px 10px;border-radius:7px;margin-bottom:6px;font-size:.8rem;line-height:1.5}
|
|||
|
|
.rec.crit{background:rgba(229,62,62,.06);border-left:3px solid var(--red)}
|
|||
|
|
.rec.warn{background:rgba(200,118,58,.07);border-left:3px solid var(--amber)}
|
|||
|
|
.rec.info{background:rgba(37,99,235,.05);border-left:3px solid var(--blue)}
|
|||
|
|
.cbar{display:flex;align-items:center;gap:8px;margin-bottom:7px}
|
|||
|
|
.cbar-lbl{width:170px;flex-shrink:0;font-size:.78rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|||
|
|
.cbar-track{flex:1;height:8px;background:var(--navy-10);border-radius:4px;overflow:hidden}
|
|||
|
|
.cbar-fill{height:100%;border-radius:4px}
|
|||
|
|
.cbar-fill.high{background:var(--red)}.cbar-fill.med{background:var(--amber)}.cbar-fill.low{background:var(--green)}
|
|||
|
|
.cbar-amt{width:90px;text-align:right;font-size:.78rem;font-weight:600;font-variant-numeric:tabular-nums;flex-shrink:0}
|
|||
|
|
/* kvitton */
|
|||
|
|
.drop-zone{border:2px dashed var(--border);border-radius:10px;padding:28px 20px;
|
|||
|
|
text-align:center;cursor:pointer;transition:all .2s;background:var(--bg)}
|
|||
|
|
.drop-zone:hover,.drop-zone.drag{border-color:var(--amber);background:var(--amber-bg)}
|
|||
|
|
.drop-zone-icon{font-size:2rem;margin-bottom:8px}
|
|||
|
|
.drop-zone-text{font-size:.85rem;color:var(--muted)}
|
|||
|
|
.drop-zone-sub{font-size:.75rem;color:var(--muted);margin-top:4px}
|
|||
|
|
.receipt-list{margin-top:14px}
|
|||
|
|
.receipt-item{display:flex;align-items:center;gap:10px;padding:8px 10px;
|
|||
|
|
background:var(--navy-05);border-radius:7px;margin-bottom:6px;font-size:.82rem}
|
|||
|
|
.receipt-item-icon{font-size:1.1rem;flex-shrink:0}
|
|||
|
|
.receipt-item-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|||
|
|
.receipt-item-size{color:var(--muted);font-size:.72rem;white-space:nowrap}
|
|||
|
|
.receipt-item-link{color:var(--amber);text-decoration:none;font-size:.78rem;white-space:nowrap}
|
|||
|
|
.receipt-item-link:hover{text-decoration:underline}
|
|||
|
|
.upload-progress{height:4px;background:var(--navy-10);border-radius:2px;margin-top:8px;overflow:hidden}
|
|||
|
|
.upload-progress-fill{height:100%;background:var(--amber);border-radius:2px;transition:width .3s}
|
|||
|
|
#footer{max-width:1320px;margin:16px auto 0;padding:12px 18px;border-top:1px solid var(--border);
|
|||
|
|
display:flex;align-items:center;justify-content:space-between;font-size:.72rem;color:var(--muted)}
|
|||
|
|
|
|||
|
|
/* ── Hamburgermeny & mobilnavigation ── */
|
|||
|
|
#ham-btn{
|
|||
|
|
display:none;background:none;border:none;color:#fff;cursor:pointer;
|
|||
|
|
padding:8px;border-radius:8px;flex-shrink:0;
|
|||
|
|
transition:background .15s;
|
|||
|
|
}
|
|||
|
|
#ham-btn:hover{background:rgba(255,255,255,.12)}
|
|||
|
|
#ham-btn svg{display:block}
|
|||
|
|
|
|||
|
|
/* Drawer */
|
|||
|
|
#mob-drawer{
|
|||
|
|
display:none;position:fixed;inset:0;z-index:2000;
|
|||
|
|
}
|
|||
|
|
#mob-drawer-backdrop{
|
|||
|
|
position:absolute;inset:0;background:rgba(0,0,30,.55);backdrop-filter:blur(2px);
|
|||
|
|
opacity:0;transition:opacity .22s ease;
|
|||
|
|
}
|
|||
|
|
#mob-drawer-panel{
|
|||
|
|
position:absolute;top:0;left:0;bottom:0;width:82%;max-width:320px;
|
|||
|
|
background:var(--navy);color:#fff;display:flex;flex-direction:column;
|
|||
|
|
transform:translateX(-100%);transition:transform .25s cubic-bezier(.4,0,.2,1);
|
|||
|
|
box-shadow:4px 0 24px rgba(0,0,0,.4);
|
|||
|
|
}
|
|||
|
|
#mob-drawer.open{display:block}
|
|||
|
|
#mob-drawer.open #mob-drawer-backdrop{opacity:1}
|
|||
|
|
#mob-drawer.open #mob-drawer-panel{transform:translateX(0)}
|
|||
|
|
|
|||
|
|
.drawer-header{
|
|||
|
|
display:flex;align-items:center;justify-content:space-between;
|
|||
|
|
padding:16px 20px;border-bottom:1px solid rgba(255,255,255,.1);
|
|||
|
|
flex-shrink:0;
|
|||
|
|
}
|
|||
|
|
.drawer-brand{display:flex;align-items:center;gap:10px}
|
|||
|
|
.drawer-logo{width:34px;height:34px;border-radius:50%;background:var(--amber);
|
|||
|
|
display:flex;align-items:center;justify-content:center;font-size:17px;font-weight:800}
|
|||
|
|
.drawer-title{font-size:.95rem;font-weight:700}
|
|||
|
|
.drawer-sub{font-size:.75rem;color:rgba(255,255,255,.5)}
|
|||
|
|
.drawer-close{background:none;border:none;color:rgba(255,255,255,.7);cursor:pointer;
|
|||
|
|
padding:6px;border-radius:8px;font-size:1.3rem;line-height:1}
|
|||
|
|
.drawer-close:hover{background:rgba(255,255,255,.12)}
|
|||
|
|
|
|||
|
|
.drawer-nav{
|
|||
|
|
flex:1;overflow-y:auto;padding:8px 0;
|
|||
|
|
}
|
|||
|
|
.drawer-nav-item{
|
|||
|
|
display:flex;align-items:center;gap:14px;padding:15px 20px;
|
|||
|
|
font-size:.97rem;font-weight:600;color:rgba(255,255,255,.75);
|
|||
|
|
cursor:pointer;border:none;background:none;width:100%;text-align:left;
|
|||
|
|
transition:background .12s,color .12s;
|
|||
|
|
border-left:3px solid transparent;
|
|||
|
|
}
|
|||
|
|
.drawer-nav-item:hover{background:rgba(255,255,255,.08);color:#fff}
|
|||
|
|
.drawer-nav-item.active{color:#fff;background:rgba(200,118,58,.18);border-left-color:var(--amber)}
|
|||
|
|
.drawer-nav-item .nav-icon{font-size:1.2rem;width:26px;text-align:center;flex-shrink:0}
|
|||
|
|
.drawer-nav-item .nav-badge{
|
|||
|
|
margin-left:auto;background:var(--red);color:#fff;font-size:.65rem;font-weight:700;
|
|||
|
|
padding:2px 7px;border-radius:10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.drawer-footer{
|
|||
|
|
padding:16px 20px;border-top:1px solid rgba(255,255,255,.1);
|
|||
|
|
display:flex;flex-direction:column;gap:8px;
|
|||
|
|
}
|
|||
|
|
.drawer-period-label{font-size:.72rem;color:rgba(255,255,255,.5);font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin-bottom:2px}
|
|||
|
|
.drawer-psel{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);
|
|||
|
|
color:#fff;border-radius:8px;padding:9px 12px;font-size:.85rem;width:100%;outline:none}
|
|||
|
|
.drawer-psel option{background:#001E50}
|
|||
|
|
.drawer-role-btn{
|
|||
|
|
display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.07);
|
|||
|
|
border:1px solid rgba(255,255,255,.15);color:rgba(255,255,255,.75);
|
|||
|
|
border-radius:8px;padding:10px 14px;font-size:.85rem;font-weight:600;
|
|||
|
|
cursor:pointer;width:100%;text-align:left;
|
|||
|
|
}
|
|||
|
|
.drawer-role-btn:hover{background:rgba(255,255,255,.13);color:#fff}
|
|||
|
|
|
|||
|
|
/* FAB — snabbknapp */
|
|||
|
|
#fab-receipt{
|
|||
|
|
display:none;
|
|||
|
|
position:fixed;right:18px;bottom:24px;z-index:1500;
|
|||
|
|
width:60px;height:60px;border-radius:50%;
|
|||
|
|
background:var(--amber);color:#fff;border:none;
|
|||
|
|
box-shadow:0 4px 20px rgba(200,118,58,.6),0 2px 6px rgba(0,0,0,.2);
|
|||
|
|
font-size:1.7rem;cursor:pointer;
|
|||
|
|
align-items:center;justify-content:center;
|
|||
|
|
transition:transform .15s,box-shadow .15s;
|
|||
|
|
}
|
|||
|
|
#fab-receipt:hover{transform:scale(1.08);box-shadow:0 6px 28px rgba(200,118,58,.75)}
|
|||
|
|
#fab-receipt:active{transform:scale(.93)}
|
|||
|
|
|
|||
|
|
/* Aktiv flik-indikator i topbar (mobil) */
|
|||
|
|
#mob-current-tab{
|
|||
|
|
display:none;
|
|||
|
|
align-items:center;gap:6px;
|
|||
|
|
font-size:.87rem;font-weight:700;color:#fff;
|
|||
|
|
background:rgba(255,255,255,.1);border-radius:8px;
|
|||
|
|
padding:5px 12px;
|
|||
|
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:160px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Responsive ── */
|
|||
|
|
/* ── Tablet ── */
|
|||
|
|
@media(max-width:1000px){
|
|||
|
|
.kpi-row{grid-template-columns:repeat(3,1fr)}
|
|||
|
|
.two-col{grid-template-columns:1fr}
|
|||
|
|
.vat-grid{grid-template-columns:1fr 1fr}
|
|||
|
|
#topbar-top{padding:0 12px;height:46px;gap:8px}
|
|||
|
|
.brand-name{display:none}
|
|||
|
|
.period-bar{order:3;width:100%}
|
|||
|
|
.topbar-right{order:2}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Mobil ── */
|
|||
|
|
@media(max-width:640px){
|
|||
|
|
/* Göm desktop-tabbar, visa hamburgare */
|
|||
|
|
#topbar-tabs{display:none}
|
|||
|
|
.period-bar{display:none}
|
|||
|
|
#ham-btn{display:flex;align-items:center;justify-content:center}
|
|||
|
|
#fab-receipt{display:flex}
|
|||
|
|
#mob-current-tab{display:flex}
|
|||
|
|
|
|||
|
|
/* Dölj desktop-only topbar-knappar */
|
|||
|
|
.btn-top{display:none}
|
|||
|
|
|
|||
|
|
/* Topbar kompakt */
|
|||
|
|
#topbar-top{padding:0 10px;height:52px;gap:8px}
|
|||
|
|
.brand-name{display:block;font-size:.88rem}
|
|||
|
|
.brand-co{display:none}
|
|||
|
|
|
|||
|
|
#main{padding:12px 10px 90px}
|
|||
|
|
.kpi-row{grid-template-columns:repeat(2,1fr);gap:8px}
|
|||
|
|
.kpi{padding:12px 14px}
|
|||
|
|
.kpi-val{font-size:1.1rem}
|
|||
|
|
.two-col{grid-template-columns:1fr}
|
|||
|
|
.form-row{grid-template-columns:1fr}
|
|||
|
|
.vat-grid{grid-template-columns:1fr}
|
|||
|
|
/* Tabell göms på mobil, kort visas istället via .tx-table-wrap / .tx-cards */
|
|||
|
|
.inv-table th:nth-child(4),
|
|||
|
|
.inv-table td:nth-child(4),
|
|||
|
|
.inv-table th:nth-child(5),
|
|||
|
|
.inv-table td:nth-child(5){display:none}
|
|||
|
|
.modal-box{max-height:95vh;margin:0;border-radius:var(--r) var(--r) 0 0;align-self:flex-end}
|
|||
|
|
.modal-bg{align-items:flex-end;padding:0}
|
|||
|
|
.tx-toolbar{flex-direction:column;align-items:stretch}
|
|||
|
|
.tx-search{min-width:unset}
|
|||
|
|
.rc-actions{gap:5px}
|
|||
|
|
.act-btn{padding:6px 10px;font-size:.77rem}
|
|||
|
|
#footer{flex-direction:column;gap:4px;text-align:center}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ═══════════════════════════════════════════════════════════
|
|||
|
|
TOOLTIP SYSTEM
|
|||
|
|
═══════════════════════════════════════════════════════════ */
|
|||
|
|
[data-tip]{position:relative;cursor:help}
|
|||
|
|
[data-tip]::after{
|
|||
|
|
content:attr(data-tip);
|
|||
|
|
position:absolute;
|
|||
|
|
bottom:calc(100% + 8px);
|
|||
|
|
left:50%;
|
|||
|
|
transform:translateX(-50%);
|
|||
|
|
background:#1a2740;
|
|||
|
|
color:#f0f4ff;
|
|||
|
|
font-size:.75rem;
|
|||
|
|
font-weight:400;
|
|||
|
|
line-height:1.45;
|
|||
|
|
padding:7px 11px;
|
|||
|
|
border-radius:7px;
|
|||
|
|
white-space:pre-wrap;
|
|||
|
|
max-width:240px;
|
|||
|
|
min-width:120px;
|
|||
|
|
text-align:left;
|
|||
|
|
box-shadow:0 4px 16px rgba(0,0,0,.35);
|
|||
|
|
pointer-events:none;
|
|||
|
|
opacity:0;
|
|||
|
|
transition:opacity .15s ease, transform .15s ease;
|
|||
|
|
transform:translateX(-50%) translateY(4px);
|
|||
|
|
z-index:9999;
|
|||
|
|
}
|
|||
|
|
[data-tip]::before{
|
|||
|
|
content:'';
|
|||
|
|
position:absolute;
|
|||
|
|
bottom:calc(100% + 3px);
|
|||
|
|
left:50%;
|
|||
|
|
transform:translateX(-50%);
|
|||
|
|
border:5px solid transparent;
|
|||
|
|
border-top-color:#1a2740;
|
|||
|
|
pointer-events:none;
|
|||
|
|
opacity:0;
|
|||
|
|
transition:opacity .15s ease;
|
|||
|
|
z-index:9999;
|
|||
|
|
}
|
|||
|
|
[data-tip]:hover::after,[data-tip]:hover::before{opacity:1;transform:translateX(-50%) translateY(0)}
|
|||
|
|
|
|||
|
|
/* Tooltips som öppnas nedåt (för top-nav) */
|
|||
|
|
[data-tip-down]{position:relative;cursor:help}
|
|||
|
|
[data-tip-down]::after{
|
|||
|
|
content:attr(data-tip-down);
|
|||
|
|
position:absolute;
|
|||
|
|
top:calc(100% + 8px);
|
|||
|
|
bottom:auto;
|
|||
|
|
left:50%;
|
|||
|
|
transform:translateX(-50%) translateY(-4px);
|
|||
|
|
background:#1a2740;
|
|||
|
|
color:#f0f4ff;
|
|||
|
|
font-size:.75rem;
|
|||
|
|
font-weight:400;
|
|||
|
|
line-height:1.45;
|
|||
|
|
padding:7px 11px;
|
|||
|
|
border-radius:7px;
|
|||
|
|
white-space:pre-wrap;
|
|||
|
|
max-width:240px;
|
|||
|
|
min-width:120px;
|
|||
|
|
text-align:left;
|
|||
|
|
box-shadow:0 4px 16px rgba(0,0,0,.35);
|
|||
|
|
pointer-events:none;
|
|||
|
|
opacity:0;
|
|||
|
|
transition:opacity .15s ease, transform .15s ease;
|
|||
|
|
z-index:9999;
|
|||
|
|
}
|
|||
|
|
[data-tip-down]::before{
|
|||
|
|
content:'';
|
|||
|
|
position:absolute;
|
|||
|
|
top:calc(100% + 3px);
|
|||
|
|
bottom:auto;
|
|||
|
|
left:50%;
|
|||
|
|
transform:translateX(-50%);
|
|||
|
|
border:5px solid transparent;
|
|||
|
|
border-top-color:transparent;
|
|||
|
|
border-bottom-color:#1a2740;
|
|||
|
|
pointer-events:none;
|
|||
|
|
opacity:0;
|
|||
|
|
transition:opacity .15s ease;
|
|||
|
|
z-index:9999;
|
|||
|
|
}
|
|||
|
|
[data-tip-down]:hover::after,[data-tip-down]:hover::before{opacity:1;transform:translateX(-50%) translateY(0)}
|
|||
|
|
|
|||
|
|
/* ═══════════════════════════════════════════════════════════
|
|||
|
|
ONBOARDING OVERLAY
|
|||
|
|
═══════════════════════════════════════════════════════════ */
|
|||
|
|
#ob-backdrop{
|
|||
|
|
position:fixed;inset:0;
|
|||
|
|
background:rgba(0,0,0,.55);
|
|||
|
|
z-index:10000;
|
|||
|
|
display:flex;align-items:center;justify-content:center;
|
|||
|
|
}
|
|||
|
|
#ob-box{
|
|||
|
|
background:#fff;
|
|||
|
|
border-radius:16px;
|
|||
|
|
padding:32px 36px;
|
|||
|
|
max-width:460px;
|
|||
|
|
width:90%;
|
|||
|
|
box-shadow:0 20px 60px rgba(0,0,0,.35);
|
|||
|
|
position:relative;
|
|||
|
|
animation:ob-in .25s ease;
|
|||
|
|
}
|
|||
|
|
@keyframes ob-in{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
|
|||
|
|
#ob-box.dark{background:#0f1b2d;color:#e8eeff}
|
|||
|
|
#ob-step-num{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#6b7fa3;margin-bottom:8px}
|
|||
|
|
#ob-icon{font-size:2.4rem;margin-bottom:10px}
|
|||
|
|
#ob-title{font-size:1.25rem;font-weight:800;margin-bottom:8px;color:#0f1b2d}
|
|||
|
|
#ob-text{font-size:.92rem;line-height:1.6;color:#3a4a6b;margin-bottom:20px}
|
|||
|
|
#ob-box.dark #ob-title{color:#e8eeff}
|
|||
|
|
#ob-box.dark #ob-text{color:#a0b0d0}
|
|||
|
|
#ob-actions{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|||
|
|
#ob-dots{display:flex;gap:6px}
|
|||
|
|
.ob-dot{width:7px;height:7px;border-radius:50%;background:#d0d8e8;transition:background .2s}
|
|||
|
|
.ob-dot.active{background:#1a56e0}
|
|||
|
|
#ob-skip{font-size:.78rem;color:#6b7fa3;cursor:pointer;background:none;border:none;padding:4px 8px}
|
|||
|
|
#ob-skip:hover{color:#1a56e0}
|
|||
|
|
#ob-next{background:#1a56e0;color:#fff;border:none;border-radius:9px;padding:9px 22px;
|
|||
|
|
font-size:.88rem;font-weight:700;cursor:pointer;transition:background .15s}
|
|||
|
|
#ob-next:hover{background:#1445b8}
|
|||
|
|
#ob-highlight{
|
|||
|
|
position:fixed;
|
|||
|
|
border-radius:10px;
|
|||
|
|
box-shadow:0 0 0 4px #1a56e0, 0 0 0 9999px rgba(0,0,0,.4);
|
|||
|
|
transition:all .3s ease;
|
|||
|
|
pointer-events:none;
|
|||
|
|
z-index:9999;
|
|||
|
|
}
|
|||
|
|
#ob-close{position:absolute;top:12px;right:14px;background:none;border:none;
|
|||
|
|
font-size:1.1rem;cursor:pointer;color:#6b7fa3;padding:4px}
|
|||
|
|
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
|
|||
|
|
<!-- HAMBURGERMENY DRAWER -->
|
|||
|
|
<div id="mob-drawer" role="dialog" aria-modal="true" aria-label="Navigation">
|
|||
|
|
<div id="mob-drawer-backdrop" onclick="closeMobDrawer()"></div>
|
|||
|
|
<div id="mob-drawer-panel">
|
|||
|
|
<div class="drawer-header">
|
|||
|
|
<div class="drawer-brand">
|
|||
|
|
<div class="drawer-logo">∞</div>
|
|||
|
|
<div>
|
|||
|
|
<div class="drawer-title">Ouroboros Finance</div>
|
|||
|
|
<div class="drawer-sub" id="drawer-co-name">LandveX AB</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button class="drawer-close" onclick="closeMobDrawer()" aria-label="Stäng">✕</button>
|
|||
|
|
</div>
|
|||
|
|
<nav class="drawer-nav" id="mob-drawer-nav">
|
|||
|
|
<button class="drawer-nav-item active" data-tab="overview" onclick="mobNavTo('overview')">
|
|||
|
|
<span class="nav-icon">📊</span> Översikt
|
|||
|
|
</button>
|
|||
|
|
<button class="drawer-nav-item" data-tab="transactions" onclick="mobNavTo('transactions')">
|
|||
|
|
<span class="nav-icon">📋</span> Transaktioner
|
|||
|
|
</button>
|
|||
|
|
<button class="drawer-nav-item" data-tab="invoices" onclick="mobNavTo('invoices')">
|
|||
|
|
<span class="nav-icon">🧾</span> Fakturor
|
|||
|
|
</button>
|
|||
|
|
<button class="drawer-nav-item" data-tab="vat" onclick="mobNavTo('vat')">
|
|||
|
|
<span class="nav-icon">🏦</span> Skatt & Moms
|
|||
|
|
</button>
|
|||
|
|
<button class="drawer-nav-item" data-tab="payroll" onclick="mobNavTo('payroll')">
|
|||
|
|
<span class="nav-icon">👥</span> Löner
|
|||
|
|
</button>
|
|||
|
|
<button class="drawer-nav-item" data-tab="period" onclick="mobNavTo('period')">
|
|||
|
|
<span class="nav-icon">📅</span> Period
|
|||
|
|
</button>
|
|||
|
|
<button class="drawer-nav-item" data-tab="tasks" onclick="mobNavTo('tasks')">
|
|||
|
|
<span class="nav-icon">✅</span> Tasks
|
|||
|
|
<span class="nav-badge" id="drawer-tasks-badge" style="display:none">!</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="drawer-nav-item" data-tab="forecast" onclick="mobNavTo('forecast')">
|
|||
|
|
<span class="nav-icon">📈</span> Prognos
|
|||
|
|
</button>
|
|||
|
|
</nav>
|
|||
|
|
<div class="drawer-footer">
|
|||
|
|
<div>
|
|||
|
|
<div class="drawer-period-label">Period</div>
|
|||
|
|
<select class="drawer-psel" id="drawer-period-sel" onchange="syncDrawerPeriod(this.value)"></select>
|
|||
|
|
</div>
|
|||
|
|
<button class="drawer-role-btn" onclick="closeMobDrawer();obShowRolePicker()">
|
|||
|
|
<span>👤</span> <span id="drawer-role-name">Byt roll / guide</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- FAB — snabbknapp för kvitto/utlägg -->
|
|||
|
|
<button id="fab-receipt" onclick="fabAddReceipt()" aria-label="Lägg till kvitto / utlägg">
|
|||
|
|
📷
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<!-- TOPBAR -->
|
|||
|
|
<div id="topbar">
|
|||
|
|
<!-- Rad 1: brand + period + status -->
|
|||
|
|
<div id="topbar-top">
|
|||
|
|
<button id="ham-btn" onclick="openMobDrawer()" aria-label="Meny" aria-expanded="false">
|
|||
|
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
|
|||
|
|
<line x1="2" y1="5.5" x2="20" y2="5.5"/>
|
|||
|
|
<line x1="2" y1="11" x2="20" y2="11"/>
|
|||
|
|
<line x1="2" y1="16.5" x2="20" y2="16.5"/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
<div class="brand">
|
|||
|
|
<div class="brand-logo">∞</div>
|
|||
|
|
<span class="brand-name">Ouroboros Finance</span>
|
|||
|
|
<span class="brand-co" id="co-name">LandveX AB</span>
|
|||
|
|
</div>
|
|||
|
|
<div id="mob-current-tab">📊 Översikt</div>
|
|||
|
|
<div style="flex:1"></div>
|
|||
|
|
<div class="period-bar">
|
|||
|
|
<div class="pmode-group">
|
|||
|
|
<button class="pmode-btn active" id="pmode-month" onclick="setPeriodMode('month')">Mån</button>
|
|||
|
|
<button class="pmode-btn" id="pmode-quarter" onclick="setPeriodMode('quarter')">Kv</button>
|
|||
|
|
<button class="pmode-btn" id="pmode-custom" onclick="setPeriodMode('custom')">…</button>
|
|||
|
|
</div>
|
|||
|
|
<div id="pw-month"><select class="psel" id="sel-month" data-tip="Välj vilket månad du
|
|||
|
|
vill se bokföringen för."></select></div>
|
|||
|
|
<div id="pw-quarter" style="display:none"><select class="psel" id="sel-quarter"></select></div>
|
|||
|
|
<div class="pcustom" id="pw-custom">
|
|||
|
|
<input type="month" class="pcustom-in" id="p-from">
|
|||
|
|
<span style="color:rgba(255,255,255,.4);font-size:.8rem">→</span>
|
|||
|
|
<input type="month" class="pcustom-in" id="p-to">
|
|||
|
|
<button class="pmode-btn active" style="background:var(--green);margin-left:2px" onclick="applyCustom()">OK</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="topbar-right">
|
|||
|
|
<div id="data-status"><span class="sdot" id="sdot"></span><span id="stext"></span></div>
|
|||
|
|
<button class="btn-top" onclick="openNewEntry()" data-tip="Skapa ett nytt verifikat
|
|||
|
|
med debet- och kreditrader">+ Verif.</button>
|
|||
|
|
<button class="btn-top" onclick="obShowRolePicker()" data-tip="Starta om
|
|||
|
|
introduktionsguiden" style="background:none;border:1px solid rgba(255,255,255,.3);color:rgba(255,255,255,.8)">?</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- Rad 2: tabbar full bredd -->
|
|||
|
|
<div id="topbar-tabs">
|
|||
|
|
<div id="tab-nav">
|
|||
|
|
<button class="tab-btn active" onclick="switchTab('overview')" data-tip-down="Sammanfattning av kassa,
|
|||
|
|
intäkter, kostnader och resultat">📊 Översikt</button>
|
|||
|
|
<button class="tab-btn" onclick="switchTab('transactions')" data-tip-down="Alla bokförda verifikat.
|
|||
|
|
Klicka för att se detaljer.">📋 Trans.</button>
|
|||
|
|
<button class="tab-btn" onclick="switchTab('invoices')" data-tip-down="Skapa och hantera fakturor.
|
|||
|
|
Betald/obetald status.">🧾 Fakturor</button>
|
|||
|
|
<button class="tab-btn" onclick="switchTab('vat')" data-tip-down="Moms, skatter och SKV-skulder.">🏦 Skatt</button>
|
|||
|
|
<button class="tab-btn" onclick="switchTab('payroll')" id="payroll-tab-btn" data-tip-down="Löner och arbetsgivaravgifter.">👥 Löner</button>
|
|||
|
|
<button class="tab-btn" onclick="switchTab('period')" data-tip-down="Öppna/stäng bokföringsperioder.">📅 Period</button>
|
|||
|
|
<button class="tab-btn" onclick="switchTab('tasks')" id="tasks-tab-btn" data-tip-down="Åtgärder som kräver
|
|||
|
|
din uppmärksamhet.">✅ Tasks</button>
|
|||
|
|
<button class="tab-btn" onclick="switchTab('forecast')" data-tip-down="Likviditetsprognos
|
|||
|
|
baserad på kända in/utflöden.">📈 Prognos</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- MAIN -->
|
|||
|
|
<div id="main">
|
|||
|
|
|
|||
|
|
<!-- ═══ ÖVERSIKT ═══ -->
|
|||
|
|
<div id="tab-overview" class="tab-sec active">
|
|||
|
|
|
|||
|
|
<div class="kpi-row">
|
|||
|
|
<div class="kpi" id="kpi-cash" onclick="showKassaModal()" style="cursor:pointer" title="Klicka för detaljer">
|
|||
|
|
<div class="kpi-icon" data-tip="Total likvida medel på
|
|||
|
|
alla bank- och kassakonton.">🏦</div>
|
|||
|
|
<div class="kpi-val" id="kpi-cash-v">—</div>
|
|||
|
|
<div class="kpi-lbl">Kassa (1930–1936) ›</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="kpi" id="kpi-rev" onclick="showIntakterModal()" style="cursor:pointer" title="Klicka för detaljer">
|
|||
|
|
<div class="kpi-icon" data-tip="Totala intäkter hittills
|
|||
|
|
denna period (konto 3xxx).">📈</div>
|
|||
|
|
<div class="kpi-val amber" id="kpi-rev-v">—</div>
|
|||
|
|
<div class="kpi-lbl">Intäkter ›</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="kpi" id="kpi-exp" onclick="showKostnaderModal()" style="cursor:pointer" title="Klicka för detaljer">
|
|||
|
|
<div class="kpi-icon" data-tip="Totala kostnader hittills
|
|||
|
|
denna period (konto 4–7xxx).">📉</div>
|
|||
|
|
<div class="kpi-val navy" id="kpi-exp-v">—</div>
|
|||
|
|
<div class="kpi-lbl">Kostnader ›</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="kpi" id="kpi-net" onclick="showResultatModal()" style="cursor:pointer" title="Klicka för detaljer">
|
|||
|
|
<div class="kpi-icon" data-tip="Resultat = Intäkter minus Kostnader.
|
|||
|
|
Positivt = vinst, negativt = förlust.">💰</div>
|
|||
|
|
<div class="kpi-val" id="kpi-net-v">—</div>
|
|||
|
|
<div class="kpi-lbl">Resultat ›</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="kpi red" onclick="showSkuldModal()" style="cursor:pointer" title="Klicka för detaljer">
|
|||
|
|
<div class="kpi-icon" data-tip="Utgående moms som ska
|
|||
|
|
betalas till Skatteverket.">🧾</div>
|
|||
|
|
<div class="kpi-val red" id="kpi-vat-v">—</div>
|
|||
|
|
<div class="kpi-lbl">Moms att betala ›</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="two-col">
|
|||
|
|
<!-- P&L -->
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-hdr">
|
|||
|
|
<span class="card-title">📊 Resultaträkning</span>
|
|||
|
|
<span class="badge" id="pl-badge">—</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body" id="pl-body"><div style="text-align:center;padding:30px;color:var(--muted)">Laddar…</div></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Höger -->
|
|||
|
|
<div>
|
|||
|
|
<!-- Snabbåtgärder -->
|
|||
|
|
<div style="margin-bottom:12px">
|
|||
|
|
<div class="rc-actions">
|
|||
|
|
<button class="act-btn primary" onclick="openNewEntry()" data-tip="Bokför en transaktion manuellt.
|
|||
|
|
Ange konton och belopp.">+ Ny verifikation</button>
|
|||
|
|
<button class="act-btn" onclick="switchTab('invoices');openNewInvoice()" data-tip="Skapa en ny utgående
|
|||
|
|
faktura till kund.">🧾 Ny faktura</button>
|
|||
|
|
<button class="act-btn" onclick="showBS()" data-tip="Visar balansräkning:
|
|||
|
|
tillgångar, skulder och eget kapital.">📊 Balansräkning</button>
|
|||
|
|
<button class="act-btn" onclick="doSIE()" data-tip="Exportera bokföringen i SIE4-format
|
|||
|
|
till din revisor.">📤 SIE4</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Senaste verifikat -->
|
|||
|
|
<div class="card" style="margin-bottom:12px">
|
|||
|
|
<div class="card-hdr">
|
|||
|
|
<span class="card-title">🗂 Senaste verifikat</span>
|
|||
|
|
<button onclick="switchTab('transactions')" style="background:none;border:none;color:var(--amber);font-size:.78rem;font-weight:600;cursor:pointer">Visa alla →</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body" style="padding:8px 12px" id="jw-body">Laddar…</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Tasks / Checkpoints -->
|
|||
|
|
<div class="card" id="tasks-badge-card" style="margin-bottom:12px;display:none">
|
|||
|
|
<div class="card-hdr">
|
|||
|
|
<span class="card-title" id="tasks-badge-title">⚠️ Kräver åtgärd</span>
|
|||
|
|
<button onclick="switchTab('tasks')" style="background:none;border:none;color:var(--amber);font-size:.78rem;font-weight:600;cursor:pointer">Visa alla →</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body" id="tasks-badge-body" style="padding:8px 12px;font-size:.8rem"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Period -->
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-hdr"><span class="card-title">📅 Periodstatus</span></div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div style="display:flex;align-items:center;justify-content:space-between">
|
|||
|
|
<div>
|
|||
|
|
<div class="pbadge open" id="ov-period-badge">● Öppen</div>
|
|||
|
|
<div style="font-size:.75rem;color:var(--muted);margin-top:4px" id="ov-period-detail">Period öppen</div>
|
|||
|
|
</div>
|
|||
|
|
<button class="btn-outline" onclick="switchTab('period')">Hantera →</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Ny verifikation (expanderbar) -->
|
|||
|
|
<div class="card" id="new-entry-card">
|
|||
|
|
<button class="entry-toggle" onclick="toggleEntry()">
|
|||
|
|
<span style="font-size:.88rem;font-weight:700;display:flex;align-items:center;gap:6px"><span style="color:var(--amber)">+</span> Ny verifikation</span>
|
|||
|
|
<span id="entry-chev" style="font-size:.75rem;color:var(--muted);transition:transform .2s">▼</span>
|
|||
|
|
</button>
|
|||
|
|
<div id="entry-body" class="entry-body">
|
|||
|
|
<div class="form-row">
|
|||
|
|
<div class="form-grp"><label class="form-lbl">Datum</label><input type="date" class="form-in" id="e-date"></div>
|
|||
|
|
<div class="form-grp"><label class="form-lbl">Beskrivning</label><input type="text" class="form-in" id="e-desc" placeholder="t.ex. Faktura #101 – Trygg Bil"></div>
|
|||
|
|
<div class="form-grp"><label class="form-lbl">Referens</label><input type="text" class="form-in" id="e-ref" placeholder="t.ex. FAK-2026-001"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="lines-hdr"><span>Konto</span><span style="text-align:right">Debet kr</span><span style="text-align:right">Kredit kr</span><span></span></div>
|
|||
|
|
<div id="e-lines"></div>
|
|||
|
|
<button id="add-line-btn" onclick="addLine()">+ Lägg till rad</button>
|
|||
|
|
<div class="bal-ind" id="bal-ind">
|
|||
|
|
<div style="display:flex;gap:16px;font-size:.8rem">
|
|||
|
|
<span>Debet: <strong id="bal-d">0 kr</strong></span>
|
|||
|
|
<span>Kredit: <strong id="bal-k">0 kr</strong></span>
|
|||
|
|
</div>
|
|||
|
|
<span id="bal-txt" style="font-size:.8rem;color:var(--muted)">Ange rader</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="form-actions">
|
|||
|
|
<button class="btn-primary" id="save-btn" onclick="saveEntry()">💾 Spara</button>
|
|||
|
|
<button class="btn-secondary" onclick="resetEntry()">Rensa</button>
|
|||
|
|
<span class="feedback" id="e-fb"></span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div><!-- /översikt -->
|
|||
|
|
|
|||
|
|
<!-- TASKS TAB -->
|
|||
|
|
<div id="tab-tasks" class="tab-sec">
|
|||
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
|||
|
|
<h2 style="margin:0;font-size:1rem;font-weight:600">✅ Åtgärdslista</h2>
|
|||
|
|
<button class="act-btn primary" onclick="loadTasks()">🔄 Uppdatera</button>
|
|||
|
|
</div>
|
|||
|
|
<div id="tasks-list">Laddar…</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ═══ TRANSAKTIONER ═══ -->
|
|||
|
|
<div id="tab-transactions" class="tab-sec">
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-hdr">
|
|||
|
|
<span class="card-title">📋 Alla transaktioner</span>
|
|||
|
|
<span class="badge" id="tx-count-badge">—</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div class="tx-toolbar">
|
|||
|
|
<input type="text" class="tx-search" id="tx-search" placeholder="Sök beskrivning, referens, konto…" oninput="txSearchDebounce()">
|
|||
|
|
<div class="tx-filter-row">
|
|||
|
|
<select class="tx-filter-sel" id="tx-src" onchange="txLoad(1)">
|
|||
|
|
<option value="">Alla källor</option>
|
|||
|
|
<option value="bank">Bankimport</option>
|
|||
|
|
<option value="manual">Manuella</option>
|
|||
|
|
</select>
|
|||
|
|
<select class="tx-filter-sel" id="tx-status-f" onchange="txLoad(1)">
|
|||
|
|
<option value="posted">Bokförda</option>
|
|||
|
|
<option value="">Alla status</option>
|
|||
|
|
<option value="draft">Utkast</option>
|
|||
|
|
<option value="void">Makulerade</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- Desktop: tabell -->
|
|||
|
|
<div class="tx-table-wrap">
|
|||
|
|
<table class="tx-table">
|
|||
|
|
<thead><tr>
|
|||
|
|
<th>Nr</th><th>Datum</th><th>Beskrivning</th><th>Källa</th><th>Konton</th><th style="text-align:right">Belopp</th><th>Status</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody id="tx-tbody"><tr><td colspan="7" class="tx-empty">Laddar…</td></tr></tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
<!-- Mobil: kort -->
|
|||
|
|
<div class="tx-cards" id="tx-cards">Laddar…</div>
|
|||
|
|
<!-- Paginering (delad) -->
|
|||
|
|
<div class="tx-pagination">
|
|||
|
|
<button class="pg-btn" id="pg-prev" onclick="txPage(-1)" disabled>← Föregående</button>
|
|||
|
|
<span id="pg-info">—</span>
|
|||
|
|
<button class="pg-btn" id="pg-next" onclick="txPage(1)" disabled>Nästa →</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ═══ FAKTUROR ═══ -->
|
|||
|
|
<div id="tab-invoices" class="tab-sec">
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-hdr">
|
|||
|
|
<span class="card-title">🧾 Fakturor</span>
|
|||
|
|
<button class="act-btn primary" onclick="openNewInvoice()">+ Ny faktura</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div id="inv-summary" style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px"></div>
|
|||
|
|
<div id="inv-wrap">
|
|||
|
|
<div id="inv-empty" style="text-align:center;padding:30px;color:var(--muted)">Laddar fakturor…</div>
|
|||
|
|
<table class="inv-table" id="inv-table" style="display:none">
|
|||
|
|
<thead><tr>
|
|||
|
|
<th>Verifikat</th><th>Kund</th><th>Datum</th><th>Beskrivning</th>
|
|||
|
|
<th style="text-align:right">Exkl moms</th><th style="text-align:right">Moms</th>
|
|||
|
|
<th style="text-align:right">Totalt</th><th>Status</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody id="inv-tbody"></tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ═══ MOMS ═══ -->
|
|||
|
|
<div id="tab-vat" class="tab-sec">
|
|||
|
|
|
|||
|
|
<!-- SKV-skulder — alltid högst upp -->
|
|||
|
|
<div class="card" style="margin-bottom:12px">
|
|||
|
|
<div class="card-hdr" style="background:rgba(255,59,48,.06)">
|
|||
|
|
<span class="card-title" style="color:#FF3B30">🏛 Skatteskulder till Skatteverket</span>
|
|||
|
|
<span id="skv-total-badge" class="badge" style="background:#FF3B30;color:#fff">—</span>
|
|||
|
|
</div>
|
|||
|
|
<div id="skv-skulder-body">Laddar…</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Momsdeklarationsunderlag -->
|
|||
|
|
<div class="card" style="margin-bottom:12px">
|
|||
|
|
<div class="card-hdr">
|
|||
|
|
<span class="card-title">🧮 Momsdeklarationsunderlag</span>
|
|||
|
|
<div style="display:flex;gap:8px;align-items:center">
|
|||
|
|
<select class="psel" id="vat-q-sel" onchange="loadVAT()" style="font-size:.78rem;padding:4px 8px">
|
|||
|
|
<option value="Q1">Q1 Jan–Mar 2026</option>
|
|||
|
|
<option value="Q2" selected>Q2 Apr–Jun 2026</option>
|
|||
|
|
<option value="Q3">Q3 Jul–Sep 2026</option>
|
|||
|
|
<option value="Q4">Q4 Okt–Dec 2026</option>
|
|||
|
|
</select>
|
|||
|
|
<button class="act-btn" onclick="exportVAT()">📄 Exportera</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body" id="vat-body">Laddar…</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Skattekalender -->
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-hdr"><span class="card-title">📋 Skattekalender</span></div>
|
|||
|
|
<div class="card-body" id="skv-calendar-body" style="padding:10px 16px;font-size:.82rem"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div></div>
|
|||
|
|
|
|||
|
|
<!-- ═══ LÖNER ═══ -->
|
|||
|
|
<div id="tab-payroll" class="tab-sec">
|
|||
|
|
<div class="kpi-row" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
|
|||
|
|
<div class="kpi navy"><div class="kpi-icon">💼</div><div class="kpi-val navy" id="pr-lon-v">—</div><div class="kpi-lbl">Bruttolöner YTD</div></div>
|
|||
|
|
<div class="kpi amber"><div class="kpi-icon">🏛</div><div class="kpi-val amber" id="pr-aga-v">—</div><div class="kpi-lbl">Arbetsgivaravgift YTD</div></div>
|
|||
|
|
<div class="kpi red" onclick="showSkuldModal()" style="cursor:pointer"><div class="kpi-icon">⚠️</div><div class="kpi-val red" id="pr-skuld-v">—</div><div class="kpi-lbl">Att betala SKV ›</div></div>
|
|||
|
|
<div class="kpi"><div class="kpi-icon">👤</div><div class="kpi-val" id="pr-antal-v">—</div><div class="kpi-lbl">Anställda / förskott</div></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card" style="margin-bottom:12px">
|
|||
|
|
<div class="card-hdr">
|
|||
|
|
<span class="card-title">👤 Löner per person</span>
|
|||
|
|
<button class="act-btn primary" onclick="switchTab('transactions');openNewEntry()">+ Bokför lön</button>
|
|||
|
|
</div>
|
|||
|
|
<div id="pr-persons-body" style="padding:0">Laddar…</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card" style="margin-bottom:12px">
|
|||
|
|
<div class="card-hdr"><span class="card-title">📅 Löner per period</span></div>
|
|||
|
|
<div id="pr-periods-body" style="padding:0">Laddar…</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-hdr" style="background:rgba(255,149,0,.08)">
|
|||
|
|
<span class="card-title">🏛 Arbetsgivaravgifter & personalskatt</span>
|
|||
|
|
<span id="pr-skv-badge" class="badge">—</span>
|
|||
|
|
</div>
|
|||
|
|
<div id="pr-skv-body" style="padding:10px 16px;font-size:.83rem">Laddar…</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
|
|||
|
|
<!-- ═══ PERIOD ═══ -->
|
|||
|
|
<div id="tab-period" class="tab-sec">
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-hdr">
|
|||
|
|
<span class="card-title">📅 Periodstängning — <span id="pc-label">—</span></span>
|
|||
|
|
<div class="pbadge open" id="pc-badge">● Öppen</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div class="wf-steps" id="wf-steps">
|
|||
|
|
<span class="wf-step active" id="wf-open">Öppen</span><span class="wf-arrow">→</span>
|
|||
|
|
<span class="wf-step" id="wf-review">Granskning</span><span class="wf-arrow">→</span>
|
|||
|
|
<span class="wf-step" id="wf-approved">Godkänd</span><span class="wf-arrow">→</span>
|
|||
|
|
<span class="wf-step" id="wf-closed">Stängd</span>
|
|||
|
|
</div>
|
|||
|
|
<ul class="chk-list" id="chk-list">
|
|||
|
|
<li class="chk-item"><span class="chk-icon pending" id="ci-bal">⏳</span><span>Balansräkning balanserar (debet = kredit)</span><span class="chk-val" id="cv-bal">—</span></li>
|
|||
|
|
<li class="chk-item"><span class="chk-icon pending" id="ci-obs">⏳</span><span>Observationskonto 1689 = 0</span><span class="chk-val" id="cv-obs">—</span></li>
|
|||
|
|
<li class="chk-item"><span class="chk-icon pending" id="ci-vat">⏳</span><span>Moms bokförd (konto 2611 ≠ 0)</span><span class="chk-val" id="cv-vat">—</span></li>
|
|||
|
|
<li class="chk-item"><span class="chk-icon pending" id="ci-dft">⏳</span><span>Inga utkast-verifikat</span><span class="chk-val" id="cv-dft">—</span></li>
|
|||
|
|
</ul>
|
|||
|
|
<div id="pc-actions" style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap"></div>
|
|||
|
|
<div id="pc-fb" style="margin-top:8px;font-size:.82rem"></div>
|
|||
|
|
<div id="pc-snapshot" style="display:none;margin-top:18px">
|
|||
|
|
<div style="font-size:.85rem;font-weight:700;margin-bottom:8px">📸 Trial Balance</div>
|
|||
|
|
<div id="pc-snap-content"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ═══ PROGNOS ═══ -->
|
|||
|
|
<div id="tab-forecast" class="tab-sec">
|
|||
|
|
<div id="fc-alert" style="display:none;margin-bottom:14px;padding:12px 16px;border-radius:10px;font-size:.85rem;font-weight:600"></div>
|
|||
|
|
<div class="kpi-row" style="grid-template-columns:repeat(4,1fr)">
|
|||
|
|
<div class="kpi navy" onclick="showKassaModal()" style="cursor:pointer"><div class="kpi-icon">💰</div><div class="kpi-val navy" id="fc-kassa-v">—</div><div class="kpi-lbl">Kassa idag ›</div></div>
|
|||
|
|
<div class="kpi red" onclick="showSkuldModal()" style="cursor:pointer"><div class="kpi-icon">🧾</div><div class="kpi-val red" id="fc-skulder-v">—</div><div class="kpi-lbl">Skulder förfallna ›</div></div>
|
|||
|
|
<div class="kpi" id="fc-netto-card" onclick="showKassaModal()" style="cursor:pointer"><div class="kpi-icon">🏦</div><div class="kpi-val" id="fc-netto-v">—</div><div class="kpi-lbl">Kassa efter skulder ›</div></div>
|
|||
|
|
<div class="kpi amber"><div class="kpi-icon">🔥</div><div class="kpi-val amber" id="fc-burn-v">—</div><div class="kpi-lbl">Burn rate /mån</div></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="two-col" style="grid-template-columns:1fr 320px">
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-hdr"><span class="card-title">📊 Likviditetsprognos — 6 månader</span><span class="badge" id="fc-runway">—</span></div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div id="fc-barchart" style="display:flex;align-items:flex-end;gap:6px;height:150px;margin-bottom:4px"></div>
|
|||
|
|
<div id="fc-barlabels" style="display:flex;gap:6px;font-size:.68rem;color:var(--muted);margin-bottom:14px"></div>
|
|||
|
|
<table style="width:100%;border-collapse:collapse;font-size:.82rem">
|
|||
|
|
<thead><tr style="border-bottom:2px solid var(--border)">
|
|||
|
|
<th style="padding:5px 8px;text-align:left;font-size:.69rem;color:var(--muted);text-transform:uppercase;font-weight:700">Månad</th>
|
|||
|
|
<th style="padding:5px 8px;text-align:right;font-size:.69rem;color:var(--muted);text-transform:uppercase;font-weight:700">Inbetalt</th>
|
|||
|
|
<th style="padding:5px 8px;text-align:right;font-size:.69rem;color:var(--muted);text-transform:uppercase;font-weight:700">Utbetalt</th>
|
|||
|
|
<th style="padding:5px 8px;text-align:right;font-size:.69rem;color:var(--muted);text-transform:uppercase;font-weight:700">Netto</th>
|
|||
|
|
<th style="padding:5px 8px;text-align:right;font-size:.69rem;color:var(--muted);text-transform:uppercase;font-weight:700">Kassa</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody id="fc-table-body"></tbody>
|
|||
|
|
</table>
|
|||
|
|
<div style="margin-top:10px;padding:8px 10px;background:var(--navy-05);border-radius:7px;font-size:.72rem;color:var(--muted)">
|
|||
|
|
Prognosen baseras på bekräftat Trygg Bil-kontrakt (222 916 kr/mån) + historiska genomsnittskostnader. Inkluderar inga nya affärer.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<div class="card" style="margin-bottom:12px">
|
|||
|
|
<div class="card-hdr" style="background:rgba(229,62,62,.06)"><span class="card-title" style="color:var(--red)">🚨 Kritiska åtgärder</span></div>
|
|||
|
|
<div class="card-body" style="padding:10px 14px" id="fc-actions"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card" style="margin-bottom:12px">
|
|||
|
|
<div class="card-hdr"><span class="card-title">🎯 Budgetrekommendationer</span></div>
|
|||
|
|
<div class="card-body" style="padding:10px 14px" id="fc-recs"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-hdr"><span class="card-title">💡 Intäktspotential</span></div>
|
|||
|
|
<div class="card-body" style="padding:10px 14px" id="fc-revenue"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-hdr"><span class="card-title">📉 Kostnadsnedbrytning (snitt/mån)</span><span class="badge" id="fc-total-burn">—</span></div>
|
|||
|
|
<div class="card-body" id="fc-costbars" style="padding:12px 16px"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div><!-- /main -->
|
|||
|
|
|
|||
|
|
<!-- Kvitto-modal -->
|
|||
|
|
<div id="modal-receipt" class="modal-bg hidden">
|
|||
|
|
<div class="modal-box" style="max-width:520px">
|
|||
|
|
<div class="modal-hdr">
|
|||
|
|
<span class="modal-title" id="rcpt-title">📎 Kvitton</span>
|
|||
|
|
<button class="modal-close" onclick="closeModal('modal-receipt')">✕</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<div id="rcpt-entry-info" style="margin-bottom:14px;padding:8px 12px;background:var(--navy-05);border-radius:7px;font-size:.8rem;color:var(--muted)"></div>
|
|||
|
|
<!-- Befintliga kvitton -->
|
|||
|
|
<div id="rcpt-existing"></div>
|
|||
|
|
<!-- Upload-zon -->
|
|||
|
|
<div class="drop-zone" id="rcpt-dropzone" onclick="document.getElementById('rcpt-file-input').click()">
|
|||
|
|
<div class="drop-zone-icon">📁</div>
|
|||
|
|
<div class="drop-zone-text">Klicka eller dra filer hit</div>
|
|||
|
|
<div class="drop-zone-sub">JPG, PNG, PDF, HEIC — max 10 MB per fil</div>
|
|||
|
|
</div>
|
|||
|
|
<input type="file" id="rcpt-file-input" multiple accept="image/*,.pdf,.heic" style="display:none" onchange="handleReceiptFiles(this.files)">
|
|||
|
|
<!-- Mobil: direkt kamera-knapp -->
|
|||
|
|
<label id="rcpt-camera-btn" style="cursor:pointer;display:inline-flex;align-items:center;gap:8px;
|
|||
|
|
background:var(--amber);color:#fff;border-radius:8px;
|
|||
|
|
padding:9px 16px;font-size:.82rem;font-weight:600;margin-top:10px;width:100%;justify-content:center;box-sizing:border-box">
|
|||
|
|
<input type="file" accept="image/*" capture="environment"
|
|||
|
|
style="display:none" onchange="handleReceiptFiles(this.files)">
|
|||
|
|
📷 Ta foto med kameran
|
|||
|
|
</label>
|
|||
|
|
<div id="rcpt-upload-list" style="margin-top:10px"></div>
|
|||
|
|
<div id="rcpt-fb" style="margin-top:8px;font-size:.82rem"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button class="btn-secondary" onclick="closeModal('modal-receipt')">Stäng</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Footer -->
|
|||
|
|
<div id="footer">
|
|||
|
|
<span id="ft-brand">Ouroboros Finance · LandveX AB · 2026</span>
|
|||
|
|
<div style="display:flex;align-items:center;gap:10px">
|
|||
|
|
<span id="ft-period"></span>
|
|||
|
|
<span id="ft-count"></span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Faktura-modal -->
|
|||
|
|
<div id="modal-inv" class="modal-bg hidden">
|
|||
|
|
<div class="modal-box">
|
|||
|
|
<div class="modal-hdr"><span class="modal-title">🧾 Ny faktura</span><button class="modal-close" onclick="closeModal('modal-inv')">✕</button></div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<div class="form-row" style="grid-template-columns:1fr 1fr">
|
|||
|
|
<div class="form-grp"><label class="form-lbl">Kund</label><input type="text" class="form-in" id="inv-cust" placeholder="Kundnamn"></div>
|
|||
|
|
<div class="form-grp"><label class="form-lbl">Fakturadatum</label><input type="date" class="form-in" id="inv-date"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="form-row" style="grid-template-columns:1fr 1fr">
|
|||
|
|
<div class="form-grp"><label class="form-lbl">Förfallodatum</label><input type="date" class="form-in" id="inv-due"></div>
|
|||
|
|
<div class="form-grp"><label class="form-lbl">Belopp exkl. moms (kr)</label><input type="number" class="form-in" id="inv-amt" min="0" step="0.01" oninput="previewInv()"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="form-row" style="grid-template-columns:1fr 1fr">
|
|||
|
|
<div class="form-grp"><label class="form-lbl">Momssats</label>
|
|||
|
|
<select class="form-in" id="inv-vat" onchange="previewInv()">
|
|||
|
|
<option value="25">25%</option><option value="12">12%</option>
|
|||
|
|
<option value="6">6%</option><option value="0">0%</option>
|
|||
|
|
</select></div>
|
|||
|
|
<div class="form-grp"><label class="form-lbl">Referens</label><input type="text" class="form-in" id="inv-ref" placeholder="ORD-2026-001"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="form-grp" style="margin-bottom:10px"><label class="form-lbl">Beskrivning</label><input type="text" class="form-in" id="inv-desc"></div>
|
|||
|
|
<div id="inv-preview" style="padding:10px;background:var(--navy-05);border-radius:7px;font-size:.82rem;display:none"></div>
|
|||
|
|
<div id="inv-fb" class="feedback" style="margin-top:8px"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button class="btn-secondary" onclick="closeModal('modal-inv')">Avbryt</button>
|
|||
|
|
<button class="btn-primary" id="inv-save" onclick="saveInvoice()">💾 Spara & bokför</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Info/detail modal -->
|
|||
|
|
<div id="modal-info" class="modal-bg hidden">
|
|||
|
|
<div class="modal-box">
|
|||
|
|
<div class="modal-hdr"><span class="modal-title" id="mi-title">—</span><button class="modal-close" onclick="closeModal('modal-info')">✕</button></div>
|
|||
|
|
<div class="modal-body" id="mi-body"></div>
|
|||
|
|
<div class="modal-footer"><button class="btn-secondary" onclick="closeModal('modal-info')">Stäng</button></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// CONFIG
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
const FISCAL = 2026;
|
|||
|
|
const TOKEN = 'wavult…2026';
|
|||
|
|
const TENANT = 'landvex';
|
|||
|
|
const MN = ['Jan','Feb','Mar','Apr','Maj','Jun','Jul','Aug','Sep','Okt','Nov','Dec'];
|
|||
|
|
|
|||
|
|
const S = {
|
|||
|
|
period: null, periodMode: 'month', token: TOKEN,
|
|||
|
|
tb: [], journal: [], accounts: [], periods: [], invoices: [],
|
|||
|
|
txPage: 1, txTotal: 0, txSearch: '', txSrc: '', txStatus: 'posted',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// UTILS
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
const fmt = v => { const a=Math.abs(v); return (v<0?'−':'')+Math.round(a).toLocaleString('sv-SE')+' kr'; };
|
|||
|
|
const esc = s => String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|||
|
|
const today = () => new Date().toISOString().slice(0,10);
|
|||
|
|
const now = () => new Date();
|
|||
|
|
const acname= a => a.account_name || a.name || '';
|
|||
|
|
const fbal = a => parseFloat(a.balance||0);
|
|||
|
|
const fdeb = a => parseFloat(a.total_debit||a.debit||0);
|
|||
|
|
const fcrd = a => parseFloat(a.total_credit||a.credit||0);
|
|||
|
|
|
|||
|
|
function pLabel(p){
|
|||
|
|
if(!p) return '—';
|
|||
|
|
if(/^Q[1-4]-\d{4}$/.test(p)){const q=p[1],y=p.split('-')[1];return`Q${q} ${['Jan–Mar','Apr–Jun','Jul–Sep','Okt–Dec'][q-1]} ${y}`;}
|
|||
|
|
if(p.includes(':')){const[a,b]=p.split(':');return`${pLabel(a)} → ${pLabel(b)}`;}
|
|||
|
|
const[y,m]=p.split('-');return`${MN[parseInt(m)-1]} ${y}`;
|
|||
|
|
}
|
|||
|
|
function pParam(p){
|
|||
|
|
if(!p) return '';
|
|||
|
|
if(/^Q[1-4]-\d{4}$/.test(p)){const q=parseInt(p[1])-1,y=p.split('-')[1];return`&period_from=${y}-${String(q*3+1).padStart(2,'0')}&period_to=${y}-${String(q*3+3).padStart(2,'0')}`;}
|
|||
|
|
if(p.includes(':')){const[f,t]=p.split(':');return`&period_from=${f}&period_to=${t}`;}
|
|||
|
|
return`&period=${p}`;
|
|||
|
|
}
|
|||
|
|
function curPeriod(){const d=new Date();return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// API
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
async function api(path, method='GET', body){
|
|||
|
|
const ctrl=new AbortController();
|
|||
|
|
const t=setTimeout(()=>ctrl.abort(),10000);
|
|||
|
|
try{
|
|||
|
|
const h={'x-internal-source':'ouroboros-system','x-tenant-id':TENANT};
|
|||
|
|
const o={headers:h,signal:ctrl.signal};
|
|||
|
|
if(method!=='GET'){o.method=method;h['Content-Type']='application/json';o.body=JSON.stringify(body);}
|
|||
|
|
const r=await fetch(path,o);
|
|||
|
|
clearTimeout(t);
|
|||
|
|
if(!r.ok) throw new Error(`HTTP ${r.status}`);
|
|||
|
|
return await r.json();
|
|||
|
|
}catch(e){clearTimeout(t);throw e;}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// STATUS
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
function setStatus(s){
|
|||
|
|
const d=document.getElementById('sdot'),tx=document.getElementById('stext');
|
|||
|
|
d.className='sdot';
|
|||
|
|
if(s==='live'){d.classList.add('live');tx.textContent='Live';}
|
|||
|
|
else if(s==='loading'){tx.textContent='';}
|
|||
|
|
else if(s==='error'){d.classList.add('err');tx.textContent='Fel';}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// PERIOD MODE
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
function setPeriodMode(m){
|
|||
|
|
S.periodMode=m;
|
|||
|
|
['month','quarter','custom'].forEach(x=>{
|
|||
|
|
document.getElementById('pmode-'+x).classList.toggle('active',x===m);
|
|||
|
|
document.getElementById('pw-'+x).style.display=x===m?(m==='custom'?'flex':''):'none';
|
|||
|
|
});
|
|||
|
|
if(m==='month') applyMonth();
|
|||
|
|
if(m==='quarter') applyQuarter();
|
|||
|
|
}
|
|||
|
|
function applyMonth(){S.period=document.getElementById('sel-month').value;loadAll();}
|
|||
|
|
function applyQuarter(){S.period=document.getElementById('sel-quarter').value;loadAll();}
|
|||
|
|
function applyCustom(){
|
|||
|
|
const f=document.getElementById('p-from').value,t=document.getElementById('p-to').value;
|
|||
|
|
if(!f||!t) return;if(f>t){alert('Från måste vara före Till');return;}
|
|||
|
|
S.period=f+':'+t;loadAll();
|
|||
|
|
}
|
|||
|
|
function buildPeriodSelects(){
|
|||
|
|
const ms=document.getElementById('sel-month');
|
|||
|
|
ms.innerHTML='';
|
|||
|
|
for(let m=1;m<=12;m++){
|
|||
|
|
const v=`${FISCAL}-${String(m).padStart(2,'0')}`;
|
|||
|
|
const o=document.createElement('option');o.value=v;o.textContent=`${MN[m-1]} ${FISCAL}`;
|
|||
|
|
if(v===S.period)o.selected=true;ms.appendChild(o);
|
|||
|
|
}
|
|||
|
|
ms.onchange=applyMonth;
|
|||
|
|
const qs=document.getElementById('sel-quarter');
|
|||
|
|
qs.innerHTML='';
|
|||
|
|
const curM=parseInt((S.period||'').split('-')[1]||6);
|
|||
|
|
for(let q=1;q<=4;q++){
|
|||
|
|
const o=document.createElement('option');
|
|||
|
|
o.value=`Q${q}-${FISCAL}`;
|
|||
|
|
o.textContent=`Q${q} ${['Jan–Mar','Apr–Jun','Jul–Sep','Okt–Dec'][q-1]} ${FISCAL}`;
|
|||
|
|
if(Math.ceil(curM/3)===q)o.selected=true;
|
|||
|
|
qs.appendChild(o);
|
|||
|
|
}
|
|||
|
|
qs.onchange=applyQuarter;
|
|||
|
|
const pf=document.getElementById('p-from'),pt=document.getElementById('p-to');
|
|||
|
|
if(!pf.value)pf.value=`${FISCAL}-01`;
|
|||
|
|
if(!pt.value)pt.value=S.period||curPeriod();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// TAB
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// MOBILNAVIGATION — Hamburgermeny & FAB
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
function openMobDrawer(){
|
|||
|
|
const d=document.getElementById('mob-drawer');
|
|||
|
|
d.classList.add('open');
|
|||
|
|
document.getElementById('ham-btn').setAttribute('aria-expanded','true');
|
|||
|
|
document.body.style.overflow='hidden';
|
|||
|
|
// Synka period-dropdown i drawer
|
|||
|
|
const mainSel=document.getElementById('sel-month');
|
|||
|
|
const drawerSel=document.getElementById('drawer-period-sel');
|
|||
|
|
if(mainSel&&drawerSel){
|
|||
|
|
drawerSel.innerHTML=mainSel.innerHTML;
|
|||
|
|
drawerSel.value=mainSel.value;
|
|||
|
|
}
|
|||
|
|
// Synka rollnamn
|
|||
|
|
const rn=document.getElementById('drawer-role-name');
|
|||
|
|
if(rn&&obProfile&&obProfile.name) rn.textContent=obProfile.name+' — byt rolle';
|
|||
|
|
}
|
|||
|
|
function closeMobDrawer(){
|
|||
|
|
const d=document.getElementById('mob-drawer');
|
|||
|
|
d.classList.remove('open');
|
|||
|
|
document.getElementById('ham-btn').setAttribute('aria-expanded','false');
|
|||
|
|
document.body.style.overflow='';
|
|||
|
|
}
|
|||
|
|
function mobNavTo(t){
|
|||
|
|
// Uppdatera aktiv i drawer
|
|||
|
|
document.querySelectorAll('#mob-drawer-nav .drawer-nav-item').forEach(el=>{
|
|||
|
|
el.classList.toggle('active', el.dataset.tab===t);
|
|||
|
|
});
|
|||
|
|
// Uppdatera indikator i topbaren
|
|||
|
|
const labels={
|
|||
|
|
overview:'📊 Översikt',transactions:'📋 Transaktioner',invoices:'🧾 Fakturor',
|
|||
|
|
vat:'🏦 Skatt & Moms',payroll:'👥 Löner',period:'📅 Period',
|
|||
|
|
tasks:'✅ Tasks',forecast:'📈 Prognos'
|
|||
|
|
};
|
|||
|
|
const cur=document.getElementById('mob-current-tab');
|
|||
|
|
if(cur) cur.textContent=labels[t]||t;
|
|||
|
|
closeMobDrawer();
|
|||
|
|
switchTab(t);
|
|||
|
|
}
|
|||
|
|
function syncDrawerPeriod(val){
|
|||
|
|
const mainSel=document.getElementById('sel-month');
|
|||
|
|
if(mainSel&&mainSel.value!==val){
|
|||
|
|
mainSel.value=val;
|
|||
|
|
mainSel.dispatchEvent(new Event('change'));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// ── Kvittostatus-helpers ──
|
|||
|
|
const EXPENSE_PFX=['4','5','6'];
|
|||
|
|
const PAYROLL_PFX=['7'];
|
|||
|
|
const INTERNAL_KW=['omvänd skatt','moms','justering','stämning','rättelse','avskrivning','överbetald','kursvinst','kursförlust','kursdiff','ingående balans','period close','opening'];
|
|||
|
|
function needsReceipt(e){
|
|||
|
|
if(e.source_type==='bank') return false;
|
|||
|
|
const desc=(e.description||'').toLowerCase();
|
|||
|
|
if(INTERNAL_KW.some(k=>desc.includes(k))) return false;
|
|||
|
|
const accts=(e.lines||[]).map(l=>l.account_number||'');
|
|||
|
|
return accts.some(a=>EXPENSE_PFX.includes(a[0])||PAYROLL_PFX.includes(a[0]));
|
|||
|
|
}
|
|||
|
|
function rcptBadge(e){
|
|||
|
|
if(!needsReceipt(e)) return '<span style="font-size:.68rem;color:var(--muted)">☑ auto</span>';
|
|||
|
|
const n=(e.metadata?.receipts||[]).length;
|
|||
|
|
return n>0
|
|||
|
|
?`<span style="font-size:.68rem;font-weight:700;color:var(--green)">✅ ${n} kvitto${n>1?'n':''}</span>`
|
|||
|
|
:'<span style="font-size:.68rem;font-weight:700;color:var(--red)">❌ Saknar</span>';
|
|||
|
|
}
|
|||
|
|
// FAB — lägg till kvitto/utlägg
|
|||
|
|
function fabAddReceipt(){
|
|||
|
|
closeMobDrawer();
|
|||
|
|
// Uppdatera indikatorer
|
|||
|
|
document.querySelectorAll('#mob-drawer-nav .drawer-nav-item').forEach(el=>{
|
|||
|
|
el.classList.toggle('active', el.dataset.tab==='transactions');
|
|||
|
|
});
|
|||
|
|
const cur=document.getElementById('mob-current-tab');
|
|||
|
|
if(cur) cur.textContent='📋 Transaktioner';
|
|||
|
|
// openNewEntry säkrar själv att rätt flik är aktiv
|
|||
|
|
openNewEntry();
|
|||
|
|
}
|
|||
|
|
// Tangentbord: Escape stänger drawer
|
|||
|
|
document.addEventListener('keydown',e=>{
|
|||
|
|
if(e.key==='Escape') closeMobDrawer();
|
|||
|
|
});
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function switchTab(t){
|
|||
|
|
document.querySelectorAll('.tab-sec').forEach(s=>s.classList.remove('active'));
|
|||
|
|
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
|||
|
|
document.getElementById('tab-'+t).classList.add('active');
|
|||
|
|
const idx={overview:0,transactions:1,invoices:2,vat:3,payroll:4,period:5,tasks:6,forecast:7};
|
|||
|
|
const tabBtns=document.querySelectorAll('.tab-btn');
|
|||
|
|
if(tabBtns[idx[t]??0]) tabBtns[idx[t]??0].classList.add('active');
|
|||
|
|
// Uppdatera mob-indikator
|
|||
|
|
const labels={
|
|||
|
|
overview:'📊 Översikt',transactions:'📋 Transaktioner',invoices:'🧾 Fakturor',
|
|||
|
|
vat:'🏦 Skatt & Moms',payroll:'👥 Löner',period:'📅 Period',
|
|||
|
|
tasks:'✅ Tasks',forecast:'📈 Prognos'
|
|||
|
|
};
|
|||
|
|
const cur=document.getElementById('mob-current-tab');
|
|||
|
|
if(cur) cur.textContent=labels[t]||t;
|
|||
|
|
if(t==='transactions') txLoad(1);
|
|||
|
|
if(t==='invoices') loadInvoices();
|
|||
|
|
if(t==='vat') { loadVAT(); loadVATExtras(); }
|
|||
|
|
if(t==='payroll') loadPayroll();
|
|||
|
|
if(t==='period') loadPeriod();
|
|||
|
|
if(t==='tasks') loadTasks();
|
|||
|
|
if(t==='forecast') loadForecast();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// LOAD ALL (overview)
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
async function loadTasks(){
|
|||
|
|
const el=document.getElementById('tasks-list');
|
|||
|
|
if(!el) return;
|
|||
|
|
el.innerHTML='<div style="padding:20px;text-align:center;color:#999">Laddar…</div>';
|
|||
|
|
try{
|
|||
|
|
const r=await api('/api/ledger/tasks');
|
|||
|
|
if(!r.ok){el.innerHTML='<div style="color:red">Fel: '+r.error+'</div>';return;}
|
|||
|
|
window._lastTasks = r.tasks || [];
|
|||
|
|
|
|||
|
|
// Info-rad (kassa/burn) — alltid synlig på overview
|
|||
|
|
const infoTask = r.tasks.find(t=>t.id==='C011-info');
|
|||
|
|
const badgeCard=document.getElementById('tasks-badge-card');
|
|||
|
|
const badgeBody=document.getElementById('tasks-badge-body');
|
|||
|
|
const badgeTitle=document.getElementById('tasks-badge-title');
|
|||
|
|
if(badgeCard){
|
|||
|
|
const actionable = r.tasks.filter(t=>t.severity!=='info');
|
|||
|
|
if(actionable.length>0){
|
|||
|
|
badgeCard.style.display='';
|
|||
|
|
const hasCrit = actionable.some(t=>t.severity==='critical');
|
|||
|
|
badgeTitle.innerHTML=(hasCrit?'⛔':'⚠️')+' <strong>'+actionable.length+'</strong> åtgärd'+(actionable.length>1?'er':'')+' kräver handling';
|
|||
|
|
badgeBody.innerHTML=actionable.slice(0,4).map(t=>{
|
|||
|
|
const col=t.severity==='critical'?'#FF3B30':t.severity==='high'?'#FF9500':'#333';
|
|||
|
|
const sym=t.severity==='critical'?'⛔':t.severity==='high'?'⚠️':'ℹ️';
|
|||
|
|
return '<div style="margin:3px 0;color:'+col+'">'+sym+' '+t.title+'</div>';
|
|||
|
|
}).join('');
|
|||
|
|
} else {
|
|||
|
|
badgeCard.style.display='none';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// tasks-tab badge
|
|||
|
|
const taskBtn=document.getElementById('tasks-tab-btn');
|
|||
|
|
const actionCount=r.tasks.filter(t=>t.severity!=='info').length;
|
|||
|
|
if(taskBtn) taskBtn.textContent='✅ Tasks'+(actionCount>0?' ('+actionCount+')':'');
|
|||
|
|
|
|||
|
|
// Bygg tasks-lista
|
|||
|
|
const sevOrder={critical:0,high:1,medium:2,info:3};
|
|||
|
|
const sorted=r.tasks.slice().sort((a,b)=>(sevOrder[a.severity]??9)-(sevOrder[b.severity]??9));
|
|||
|
|
|
|||
|
|
// Kassa-info-panel
|
|||
|
|
const infoRow = infoTask ? `<div style="background:#F2F2F7;border-radius:10px;padding:12px 16px;margin-bottom:12px;display:flex;flex-wrap:wrap;gap:16px">
|
|||
|
|
${infoTask.description.split('.').filter(Boolean).map(s=>'<span style="font-size:.8rem;color:#444">'+s.trim()+'</span>').join('<span style="color:#ccc"> | </span>')}
|
|||
|
|
</div>` : '';
|
|||
|
|
|
|||
|
|
const actionItems = sorted.filter(t=>t.severity!=='info');
|
|||
|
|
if(actionItems.length===0){
|
|||
|
|
el.innerHTML=infoRow+'<div class="tasks-ok">✅ Inga öppna åtgärder</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
el.innerHTML = infoRow + actionItems.map(t=>{
|
|||
|
|
const col={critical:'#FF3B30',high:'#FF9500',medium:'#FFCC00'}[t.severity]||'#8E8E93';
|
|||
|
|
const label={critical:'⛔ Kritisk',high:'⚠️ Hög prioritet',medium:'ℹ️ Medium'}[t.severity]||t.severity;
|
|||
|
|
const amt=t.amount?'<div style="font-size:.78rem;font-weight:700;margin-top:4px">'+Math.round(t.amount).toLocaleString('sv-SE')+' kr</div>':'';
|
|||
|
|
const link=t.action_url?'<a href="'+t.action_url+'" target="_blank" style="background:#007AFF;color:#fff;border-radius:6px;padding:5px 14px;font-size:.75rem;text-decoration:none;white-space:nowrap;flex-shrink:0">Åtgärda →</a>':'';
|
|||
|
|
return '<div class="task-item '+t.severity+'" style="border-left:4px solid '+col+'">'+
|
|||
|
|
'<div style="flex:1">'+
|
|||
|
|
'<div style="font-size:.7rem;font-weight:700;color:'+col+';text-transform:uppercase;margin-bottom:3px">'+label+'</div>'+
|
|||
|
|
'<div style="font-size:.84rem;font-weight:600;color:#1c1c1e">'+t.title+'</div>'+
|
|||
|
|
'<div style="font-size:.76rem;color:#666;margin-top:3px">'+t.description+'</div>'+
|
|||
|
|
(t.period?'<div style="font-size:.74rem;color:#999;margin-top:2px">📅 '+t.period+'</div>':'')+
|
|||
|
|
amt+'</div>'+link+'</div>';
|
|||
|
|
}).join('');
|
|||
|
|
}catch(e){el.innerHTML='<div style="color:red">Fel: '+e.message+'</div>';}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
async function loadAll(){
|
|||
|
|
setStatus('loading');
|
|||
|
|
buildPeriodSelects();
|
|||
|
|
loadTasks();
|
|||
|
|
try{
|
|||
|
|
const pp=pParam(S.period);
|
|||
|
|
const[acR,tbR,jR,prR]=await Promise.all([
|
|||
|
|
api(`/api/ledger/accounts`),
|
|||
|
|
api(`/api/ledger/trial-balance?fiscal_year=${FISCAL}${pp}`),
|
|||
|
|
api(`/api/ledger/journal?fiscal_year=${FISCAL}${pp}&limit=50&status=posted`),
|
|||
|
|
api(`/api/ledger/periods?fiscal_year=${FISCAL}`),
|
|||
|
|
]);
|
|||
|
|
S.accounts=acR.accounts||[];
|
|||
|
|
S.tb=tbR.accounts||[];
|
|||
|
|
S.journal=jR.entries||[];
|
|||
|
|
S.periods=prR.periods||[];
|
|||
|
|
allAccounts=S.accounts;
|
|||
|
|
setStatus('live');
|
|||
|
|
renderOverview();
|
|||
|
|
}catch(e){
|
|||
|
|
console.warn('Load failed:',e.message);
|
|||
|
|
setStatus('error');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// KPI DRILL-DOWN MODALER
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
function fmtRow(label, amount, sub='', color='') {
|
|||
|
|
const col = color ? `color:${color};` : '';
|
|||
|
|
return `<div style="display:flex;justify-content:space-between;align-items:baseline;padding:7px 0;border-bottom:1px solid #F2F2F7">
|
|||
|
|
<span style="font-size:.83rem;color:#444">${label}${sub?'<div style="font-size:.72rem;color:#999;margin-top:1px">'+sub+'</div>':''}</span>
|
|||
|
|
<span style="font-size:.88rem;font-weight:700;${col}white-space:nowrap;margin-left:12px">${fmtAmt(amount)}</span>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
function fmtAmt(n) {
|
|||
|
|
if(n==null||isNaN(n)) return '—';
|
|||
|
|
return Math.round(n).toLocaleString('sv-SE') + ' kr';
|
|||
|
|
}
|
|||
|
|
function totalRow(label, amount, color='') {
|
|||
|
|
const col = color ? `color:${color};` : '';
|
|||
|
|
return `<div style="display:flex;justify-content:space-between;align-items:baseline;padding:9px 0;margin-top:4px;border-top:2px solid #1c1c1e">
|
|||
|
|
<span style="font-size:.85rem;font-weight:700">${label}</span>
|
|||
|
|
<span style="font-size:.92rem;font-weight:800;${col}">${fmtAmt(amount)}</span>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showKassaModal() {
|
|||
|
|
const tb = S.tb;
|
|||
|
|
const accounts = tb.filter(a => /^19/.test(a.account_number));
|
|||
|
|
const kassaMap = {
|
|||
|
|
'1930': 'Företagskonto Nordea',
|
|||
|
|
'1932': 'Revolut SEK',
|
|||
|
|
'1933': 'Revolut GBP',
|
|||
|
|
'1934': 'Revolut EUR',
|
|||
|
|
'1935': 'Revolut USD',
|
|||
|
|
'1936': 'Revolut THB',
|
|||
|
|
};
|
|||
|
|
const skulder2650 = Math.max(0, -(parseFloat(tb.find(a=>a.account_number==='2650')?.balance||0)));
|
|||
|
|
const skulder2710 = Math.max(0, -(parseFloat(tb.find(a=>a.account_number==='2710')?.balance||0)));
|
|||
|
|
const skulder2731 = Math.max(0, -(parseFloat(tb.find(a=>a.account_number==='2731')?.balance||0)));
|
|||
|
|
const totalSkulder = skulder2650 + skulder2710 + skulder2731;
|
|||
|
|
const totalKassa = accounts.reduce((s,a)=>s+parseFloat(a.balance||0),0);
|
|||
|
|
const netto = totalKassa - totalSkulder;
|
|||
|
|
|
|||
|
|
let html = '<div style="font-size:.78rem;color:#999;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">Likvida medel</div>';
|
|||
|
|
for (const a of accounts) {
|
|||
|
|
const bal = parseFloat(a.balance||0);
|
|||
|
|
if (Math.abs(bal) < 1) continue;
|
|||
|
|
const name = kassaMap[a.account_number] || a.account_name || a.account_number;
|
|||
|
|
html += fmtRow(name, bal, a.account_number);
|
|||
|
|
}
|
|||
|
|
html += totalRow('Total kassa', totalKassa);
|
|||
|
|
|
|||
|
|
html += '<div style="font-size:.78rem;color:#999;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin:16px 0 8px">Förfallna skulder (dras av)</div>';
|
|||
|
|
if (skulder2650 > 0) html += fmtRow('Momsdeklaration (2650)', -skulder2650, 'Betala till Skatteverket', '#FF3B30');
|
|||
|
|
if (skulder2710 > 0) html += fmtRow('Personalskatt (2710)', -skulder2710, 'Betala till Skatteverket', '#FF3B30');
|
|||
|
|
if (skulder2731 > 0) html += fmtRow('Arbetsgivaravgift (2731)', -skulder2731, 'Betala till Skatteverket', '#FF9500');
|
|||
|
|
if (totalSkulder === 0) html += '<div style="color:#34C759;font-size:.82rem;padding:8px 0">✅ Inga förfallna skulder</div>';
|
|||
|
|
html += totalRow('Kassa efter skulder', netto, netto >= 0 ? '#34C759' : '#FF3B30');
|
|||
|
|
|
|||
|
|
openInfo('🏦 Kassa & likviditet', html);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showSkuldModal() {
|
|||
|
|
const tb = S.tb;
|
|||
|
|
const skuldKonton = [
|
|||
|
|
{ nr: '2650', namn: 'Momsdeklaration', not: 'Betala till Skatteverket', sev: 'critical' },
|
|||
|
|
{ nr: '2710', namn: 'Personalskatt', not: 'Betala till Skatteverket senast 12:e', sev: 'critical' },
|
|||
|
|
{ nr: '2731', namn: 'Arbetsgivaravgift', not: 'Betala tillsammans med personalskatt', sev: 'critical' },
|
|||
|
|
{ nr: '2611', namn: 'Utgående moms 25% (ej deklarerad)', not: 'Kvar i 2611, ej omförd till 2650', sev: 'high' },
|
|||
|
|
{ nr: '2615', namn: 'Moms omvänd skattskyldighet', not: 'Matchar 2645 — netto 0', sev: 'info' },
|
|||
|
|
];
|
|||
|
|
let html = '<div style="font-size:.78rem;color:#999;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">Skatteskulder</div>';
|
|||
|
|
let total = 0;
|
|||
|
|
for (const k of skuldKonton) {
|
|||
|
|
const a = tb.find(x => x.account_number === k.nr);
|
|||
|
|
const skuld = a ? Math.max(0, -parseFloat(a.balance||0)) : 0;
|
|||
|
|
if (skuld < 1 && k.sev !== 'info') continue;
|
|||
|
|
const col = k.sev==='critical'?'#FF3B30':k.sev==='high'?'#FF9500':'#8E8E93';
|
|||
|
|
html += fmtRow(k.namn, skuld>0?-skuld:0, k.not, skuld>0?col:'#34C759');
|
|||
|
|
if (k.sev !== 'info') total += skuld;
|
|||
|
|
}
|
|||
|
|
html += totalRow('Totalt att betala nu', -total, total>0?'#FF3B30':'#34C759');
|
|||
|
|
|
|||
|
|
html += '<div style="font-size:.78rem;color:#999;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin:16px 0 8px">Övriga skulder</div>';
|
|||
|
|
const ovriga = tb.filter(a => a.account_number.startsWith('2') && !['2650','2710','2731','2611','2615','2645','2099'].includes(a.account_number));
|
|||
|
|
for (const a of ovriga) {
|
|||
|
|
const skuld = Math.max(0, -parseFloat(a.balance||0));
|
|||
|
|
if (skuld < 100) continue;
|
|||
|
|
html += fmtRow(a.account_name||a.account_number, -skuld, a.account_number);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
openInfo('🧾 Skulder — fullständig lista', html);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showIntakterModal() {
|
|||
|
|
const tb = S.tb;
|
|||
|
|
const intakter = tb.filter(a => a.account_number.startsWith('3'));
|
|||
|
|
let html = '<div style="font-size:.78rem;color:#999;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">Intäktskonton</div>';
|
|||
|
|
let total = 0;
|
|||
|
|
for (const a of intakter.sort((x,y)=>x.account_number.localeCompare(y.account_number))) {
|
|||
|
|
const belopp = Math.abs(parseFloat(a.balance||0));
|
|||
|
|
if (belopp < 1) continue;
|
|||
|
|
html += fmtRow(a.account_name||a.account_number, belopp, a.account_number);
|
|||
|
|
total += belopp;
|
|||
|
|
}
|
|||
|
|
html += totalRow('Totala intäkter', total, '#34C759');
|
|||
|
|
openInfo('📈 Intäkter — detalj', html);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showKostnaderModal() {
|
|||
|
|
const tb = S.tb;
|
|||
|
|
const klass = {'4':'Varor & material','5':'Lokalkostnader, resor','6':'Externa tjänster, IT','7':'Personalkostnader','8':'Finansiella'};
|
|||
|
|
let html = '';
|
|||
|
|
let grandTotal = 0;
|
|||
|
|
for (const [k, rubrik] of Object.entries(klass)) {
|
|||
|
|
const konton = tb.filter(a => a.account_number.startsWith(k));
|
|||
|
|
const klassTotal = konton.reduce((s,a)=>s+Math.max(0,parseFloat(a.balance||0)),0);
|
|||
|
|
if (klassTotal < 1) continue;
|
|||
|
|
html += `<div style="font-size:.78rem;color:#999;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin:${html?'14px':0} 0 6px">${rubrik}</div>`;
|
|||
|
|
for (const a of konton.sort((x,y)=>Math.abs(parseFloat(y.balance||0))-Math.abs(parseFloat(x.balance||0)))) {
|
|||
|
|
const kost = Math.max(0, parseFloat(a.balance||0));
|
|||
|
|
if (kost < 100) continue;
|
|||
|
|
html += fmtRow(a.account_name||a.account_number, kost, a.account_number);
|
|||
|
|
}
|
|||
|
|
grandTotal += klassTotal;
|
|||
|
|
}
|
|||
|
|
html += totalRow('Totala kostnader', grandTotal, '#FF3B30');
|
|||
|
|
openInfo('📉 Kostnader — detalj', html);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showResultatModal() {
|
|||
|
|
const tb = S.tb;
|
|||
|
|
const intakter = tb.filter(a=>a.account_number.startsWith('3')).reduce((s,a)=>s+Math.abs(parseFloat(a.balance||0)),0);
|
|||
|
|
const kostnader = tb.filter(a=>/^[4-8]/.test(a.account_number)).reduce((s,a)=>s+Math.max(0,parseFloat(a.balance||0)),0);
|
|||
|
|
const resultat = intakter - kostnader;
|
|||
|
|
const skatt = Math.max(0, resultat * 0.206);
|
|||
|
|
const efterSkatt = resultat - skatt;
|
|||
|
|
const html =
|
|||
|
|
fmtRow('Intäkter (klass 3)', intakter, 'Fakturerad omsättning', '#34C759') +
|
|||
|
|
fmtRow('Rörelsekostnader (klass 4-6)', -kostnader, 'Varor, resor, IT, tjänster') +
|
|||
|
|
fmtRow('Personalkostnader (klass 7)', 0, 'Ingår i rörelsekostnader') +
|
|||
|
|
totalRow('Rörelseresultat', resultat, resultat>=0?'#34C759':'#FF3B30') +
|
|||
|
|
'<div style="margin-top:12px"></div>' +
|
|||
|
|
fmtRow('Bolagsskatt 20,6% (beräknad)', resultat>0?-skatt:0, 'Ej bokförd') +
|
|||
|
|
totalRow('Resultat efter skatt (est.)', resultat>0?efterSkatt:resultat, resultat>0&&efterSkatt>=0?'#34C759':'#FF3B30');
|
|||
|
|
openInfo('💰 Resultat — detalj', html);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// OVERVIEW RENDER
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
function renderOverview(){
|
|||
|
|
const tb=S.tb;
|
|||
|
|
const cash =tb.filter(a=>/^19/.test(a.account_number)).reduce((s,a)=>s+fbal(a),0);
|
|||
|
|
const revenue =tb.filter(a=>/^3/.test(a.account_number)).reduce((s,a)=>s+Math.abs(fbal(a)),0);
|
|||
|
|
const expenses=tb.filter(a=>/^[4-8]/.test(a.account_number)).reduce((s,a)=>s+Math.abs(fbal(a)),0);
|
|||
|
|
const net=revenue-expenses;
|
|||
|
|
// Moms: 2611 kredit - 2645 debet
|
|||
|
|
const out2611=Math.abs(fbal(tb.find(a=>a.account_number==='2611')||{}));
|
|||
|
|
const in2645 =Math.abs(fbal(tb.find(a=>a.account_number==='2645')||{}));
|
|||
|
|
const vat=out2611-in2645;
|
|||
|
|
|
|||
|
|
function kpi(id,valId,v,color){
|
|||
|
|
document.getElementById(id).className=`kpi ${color}`;
|
|||
|
|
const el=document.getElementById(valId);el.className=`kpi-val ${color}`;el.textContent=fmt(v);
|
|||
|
|
}
|
|||
|
|
kpi('kpi-cash','kpi-cash-v',cash,cash>=0?'green':'red');
|
|||
|
|
kpi('kpi-rev','kpi-rev-v',revenue,'amber');
|
|||
|
|
kpi('kpi-exp','kpi-exp-v',expenses,'navy');
|
|||
|
|
kpi('kpi-net','kpi-net-v',net,net>=0?'green':'red');
|
|||
|
|
document.getElementById('kpi-vat-v').textContent=fmt(vat);
|
|||
|
|
|
|||
|
|
document.getElementById('pl-badge').textContent=pLabel(S.period);
|
|||
|
|
document.getElementById('ft-period').textContent=pLabel(S.period);
|
|||
|
|
document.getElementById('ft-count').textContent=S.journal.length?`${S.journal.length} verifikat (sida 1)`:'';
|
|||
|
|
renderPL(tb,revenue,expenses,net);
|
|||
|
|
renderJW();
|
|||
|
|
renderPeriodBadge();
|
|||
|
|
loadVATExtras();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderPL(tb,rev,exp,net){
|
|||
|
|
const body=document.getElementById('pl-body');
|
|||
|
|
const revA=tb.filter(a=>/^3/.test(a.account_number)&&Math.abs(fbal(a))>0.005);
|
|||
|
|
const expA=tb.filter(a=>/^[4-8]/.test(a.account_number)&&Math.abs(fbal(a))>0.005);
|
|||
|
|
if(rev===0&&exp===0){body.innerHTML=`<div style="text-align:center;padding:30px;color:var(--muted)">📭 Inga transaktioner denna period</div>`;return;}
|
|||
|
|
let h='<div style="margin-bottom:16px"><div class="pl-sec-title">Intäkter</div>';
|
|||
|
|
revA.forEach(a=>{h+=`<div class="pl-row"><span class="pl-acct">${esc(acname(a))}<span class="pl-code">${a.account_number}</span></span><span class="pl-amt">${fmt(Math.abs(fbal(a)))}</span></div>`;});
|
|||
|
|
h+=`<div class="pl-total"><span class="pl-total-lbl">SUMMA INTÄKTER</span><span class="pl-total-amt" style="color:var(--green)">${fmt(rev)}</span></div></div>`;
|
|||
|
|
h+='<div style="margin-bottom:10px"><div class="pl-sec-title">Kostnader</div>';
|
|||
|
|
expA.forEach(a=>{h+=`<div class="pl-row"><span class="pl-acct">${esc(acname(a))}<span class="pl-code">${a.account_number}</span></span><span class="pl-amt">${fmt(Math.abs(fbal(a)))}</span></div>`;});
|
|||
|
|
h+=`<div class="pl-total"><span class="pl-total-lbl">SUMMA KOSTNADER</span><span class="pl-total-amt">${fmt(exp)}</span></div></div>`;
|
|||
|
|
h+=`<div class="pl-result ${net>=0?'pos':'neg'}"><span class="pl-result-lbl">🏁 Rörelseresultat</span><span class="pl-result-amt" style="color:${net>=0?'var(--green)':'var(--red)'}">${net>=0?'+':''}${fmt(net)}</span></div>`;
|
|||
|
|
body.innerHTML=h;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderJW(){
|
|||
|
|
const b=document.getElementById('jw-body');
|
|||
|
|
const en=[...S.journal].sort((a,c)=>new Date(c.entry_date||c.date)-new Date(a.entry_date||a.date)).slice(0,8);
|
|||
|
|
if(!en.length){b.innerHTML='<div style="text-align:center;padding:14px;color:var(--muted);font-size:.82rem">Inga verifikat denna period</div>';return;}
|
|||
|
|
b.innerHTML=en.map(e=>{
|
|||
|
|
const d=(e.entry_date||e.date||'').toString().slice(0,10);
|
|||
|
|
const nr=e.entry_number||'—';
|
|||
|
|
const ls=e.lines||[];
|
|||
|
|
const amt=ls.reduce((s,l)=>s+parseFloat(l.debit||0),0);
|
|||
|
|
const isBank=e.source_type==='bank';
|
|||
|
|
const jwMiss=needsReceipt(e)&&!(e.metadata?.receipts||[]).length;
|
|||
|
|
return`<div class="jw-row" onclick="openEntryModal('${e.id||''}')" style="${jwMiss?'border-left:3px solid var(--red);padding-left:9px':''}">
|
|||
|
|
<span class="jw-nr">#${nr}</span>
|
|||
|
|
<span class="jw-date">${d}</span>
|
|||
|
|
<span class="jw-desc">${esc(e.description||'')}${isBank?'<span class="tag bank">BANK</span>':''}${jwMiss?'<span style="font-size:.65rem;font-weight:700;color:var(--red);margin-left:4px">❌</span>':''}</span>
|
|||
|
|
<span class="jw-amt">${amt>0?fmt(amt):'—'}</span>
|
|||
|
|
</div>`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderPeriodBadge(){
|
|||
|
|
const p=S.periods.find(x=>x.period===S.period);
|
|||
|
|
const st=p?.status||'open';
|
|||
|
|
const lbl={open:'Öppen',review:'Under granskning',approved:'Godkänd',closed:'Stängd'};
|
|||
|
|
const cls={open:'open',review:'review',approved:'review',closed:'closed'};
|
|||
|
|
const b=document.getElementById('ov-period-badge');
|
|||
|
|
b.className=`pbadge ${cls[st]||'open'}`;b.textContent=`● ${lbl[st]||st}`;
|
|||
|
|
document.getElementById('ov-period-detail').textContent=
|
|||
|
|
{open:'Period öppen för bokföring',review:'Inväntar granskning',approved:'Godkänd av revisor',closed:'Stängd — inga ändringar'}[st]||'';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// BALANSRÄKNING MODAL
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
function showBS(){
|
|||
|
|
const tb=S.tb;
|
|||
|
|
const assets=tb.filter(a=>/^1/.test(a.account_number)&&Math.abs(fbal(a))>0.005);
|
|||
|
|
const equity=tb.filter(a=>/^2/.test(a.account_number)&&Math.abs(fbal(a))>0.005);
|
|||
|
|
const tA=assets.reduce((s,a)=>s+fbal(a),0);
|
|||
|
|
const tE=equity.reduce((s,a)=>s+Math.abs(fbal(a)),0);
|
|||
|
|
const diff=Math.abs(tA-tE);
|
|||
|
|
function rows(arr,abs){return arr.map(a=>`<tr><td style="font-family:monospace;font-size:.78rem">${a.account_number}</td><td>${esc(acname(a))}</td><td style="text-align:right;font-variant-numeric:tabular-nums">${fmt(abs?Math.abs(fbal(a)):fbal(a))}</td></tr>`).join('');}
|
|||
|
|
const h=`<table style="width:100%;border-collapse:collapse;font-size:.83rem">
|
|||
|
|
<thead><tr style="border-bottom:2px solid var(--border)"><th style="padding:4px 6px;text-align:left;font-size:.68rem;color:var(--muted)">Konto</th><th style="padding:4px 6px;text-align:left;font-size:.68rem;color:var(--muted)">Namn</th><th style="padding:4px 6px;text-align:right;font-size:.68rem;color:var(--muted)">Saldo</th></tr></thead><tbody>
|
|||
|
|
<tr><td colspan="3" style="padding:8px 6px 3px;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">TILLGÅNGAR</td></tr>
|
|||
|
|
${rows(assets,false)}
|
|||
|
|
<tr style="background:var(--navy-05)"><td colspan="2" style="padding:6px;font-weight:700">SUMMA TILLGÅNGAR</td><td style="padding:6px;text-align:right;font-weight:800;color:var(--green)">${fmt(tA)}</td></tr>
|
|||
|
|
<tr><td colspan="3" style="padding:10px 6px 3px;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">EGET KAPITAL & SKULDER</td></tr>
|
|||
|
|
${rows(equity,true)}
|
|||
|
|
<tr style="background:var(--navy-05)"><td colspan="2" style="padding:6px;font-weight:700">SUMMA EK & SKULDER</td><td style="padding:6px;text-align:right;font-weight:800;${diff<1?'color:var(--green)':'color:var(--red)'}">${fmt(tE)}</td></tr>
|
|||
|
|
</tbody></table>
|
|||
|
|
${diff<1?'<div style="margin-top:10px;padding:8px 12px;background:rgba(39,174,96,.08);border:1px solid rgba(39,174,96,.2);border-radius:7px;font-size:.8rem;color:var(--green)">✅ Balansräkningen balanserar</div>':'<div style="margin-top:10px;padding:8px 12px;background:rgba(229,62,62,.07);border:1px solid rgba(229,62,62,.2);border-radius:7px;font-size:.8rem;color:var(--red)">⚠️ Differens: '+fmt(tA-tE)+'</div>'}`;
|
|||
|
|
openInfo(`📊 Balansräkning — ${pLabel(S.period)}`,h);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// TRANSAKTIONER
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
const TX_LIMIT=50;
|
|||
|
|
let txSearchTimer=null;
|
|||
|
|
function txSearchDebounce(){clearTimeout(txSearchTimer);txSearchTimer=setTimeout(()=>txLoad(1),350);}
|
|||
|
|
|
|||
|
|
async function txLoad(page){
|
|||
|
|
S.txPage=page||1;
|
|||
|
|
S.txSearch=document.getElementById('tx-search').value.trim();
|
|||
|
|
S.txSrc=document.getElementById('tx-src').value;
|
|||
|
|
S.txStatus=document.getElementById('tx-status-f').value;
|
|||
|
|
const pp=pParam(S.period);
|
|||
|
|
const offset=(S.txPage-1)*TX_LIMIT;
|
|||
|
|
let url=`/api/ledger/journal?fiscal_year=${FISCAL}${pp}&limit=${TX_LIMIT}&offset=${offset}`;
|
|||
|
|
if(S.txSrc) url+=`&source_type=${S.txSrc}`;
|
|||
|
|
if(S.txStatus) url+=`&status=${S.txStatus}`;
|
|||
|
|
if(S.txSearch) url+=`&search=${encodeURIComponent(S.txSearch)}`;
|
|||
|
|
const tbody=document.getElementById('tx-tbody');
|
|||
|
|
const tcards=document.getElementById('tx-cards');
|
|||
|
|
tbody.innerHTML='<tr><td colspan="7" class="tx-empty">Laddar…</td></tr>';
|
|||
|
|
tcards.innerHTML='<div class="tx-card-empty">Laddar…</div>';
|
|||
|
|
try{
|
|||
|
|
const r=await api(url);
|
|||
|
|
if(!r.ok && !r.entries){
|
|||
|
|
const msg=r.error||'Kunde inte hämta transaktioner';
|
|||
|
|
tbody.innerHTML=`<tr><td colspan="7" class="tx-empty">⚠️ ${msg}</td></tr>`;
|
|||
|
|
tcards.innerHTML=`<div class="tx-card-empty">⚠️ ${msg}</div>`;
|
|||
|
|
updatePager();return;
|
|||
|
|
}
|
|||
|
|
const entries=r.entries||[];
|
|||
|
|
S.txTotal=r.total||r.count||entries.length;
|
|||
|
|
document.getElementById('tx-count-badge').textContent=`${S.txTotal} verifikat`;
|
|||
|
|
if(!entries.length){
|
|||
|
|
tbody.innerHTML='<tr><td colspan="7" class="tx-empty">Inga verifikat matchar filtret</td></tr>';
|
|||
|
|
tcards.innerHTML='<div class="tx-card-empty">Inga verifikat matchar filtret</div>';
|
|||
|
|
updatePager();return;
|
|||
|
|
}
|
|||
|
|
// ── Desktop: tabellrader ──
|
|||
|
|
tbody.innerHTML=entries.map(e=>{
|
|||
|
|
const d=(e.entry_date||e.date||'').toString().slice(0,10);
|
|||
|
|
const nr=e.entry_number||'—';
|
|||
|
|
const lines=e.lines||[];
|
|||
|
|
const amt=lines.reduce((s,l)=>s+parseFloat(l.debit||0),0);
|
|||
|
|
const accts=[...new Set(lines.map(l=>l.account_number))].slice(0,3).join(', ');
|
|||
|
|
const srcCls=e.source_type==='bank'?'src-bank':'src-manual';
|
|||
|
|
const srcLbl=e.source_type==='bank'?'BANK':'MAN';
|
|||
|
|
const stBadge=e.status==='posted'?'<span style="color:var(--green);font-size:.7rem;font-weight:700">✓ Bokförd</span>':
|
|||
|
|
e.status==='void'?'<span style="color:var(--red);font-size:.7rem">✗ Makulerad</span>':
|
|||
|
|
'<span style="color:var(--amber);font-size:.7rem">⏳ Utkast</span>';
|
|||
|
|
const trBg=needsReceipt(e)&&!(e.metadata?.receipts||[]).length?'background:rgba(229,62,62,.05)':'';
|
|||
|
|
return`<tr onclick="openEntryModal('${e.id}')" style="${trBg}">
|
|||
|
|
<td><span class="tx-nr">#${nr}</span></td>
|
|||
|
|
<td style="white-space:nowrap">${d}</td>
|
|||
|
|
<td style="max-width:280px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis" title="${esc(e.description||'')}">${esc(e.description||'')}</td>
|
|||
|
|
<td><span class="${srcCls}">${srcLbl}</span></td>
|
|||
|
|
<td style="font-size:.75rem;color:var(--muted)">${esc(accts)}</td>
|
|||
|
|
<td class="tx-amt">${amt>0?fmt(amt):'—'}</td>
|
|||
|
|
<td style="text-align:center">${rcptBadge(e)}</td>
|
|||
|
|
</tr>`;
|
|||
|
|
}).join('');
|
|||
|
|
// ── Mobil: kort ──
|
|||
|
|
tcards.innerHTML=entries.map(e=>{
|
|||
|
|
const d=(e.entry_date||e.date||'').toString().slice(0,10).slice(5); // MM-DD
|
|||
|
|
const dFull=(e.entry_date||e.date||'').toString().slice(0,10);
|
|||
|
|
const nr=e.entry_number||'—';
|
|||
|
|
const lines=e.lines||[];
|
|||
|
|
const amt=lines.reduce((s,l)=>s+parseFloat(l.debit||0),0);
|
|||
|
|
const accts=[...new Set(lines.map(l=>l.account_number))].slice(0,4).join(' · ');
|
|||
|
|
const isBank=e.source_type==='bank';
|
|||
|
|
const stColor=e.status==='posted'?'var(--green)':e.status==='void'?'var(--red)':'var(--amber)';
|
|||
|
|
const stText=e.status==='posted'?'✓ Bokförd':e.status==='void'?'✗ Makulerad':'⏳ Utkast';
|
|||
|
|
const cardBorder=needsReceipt(e)&&!(e.metadata?.receipts||[]).length?'border-left:3px solid var(--red)':'';
|
|||
|
|
return`<div class="tx-card" onclick="openEntryModal('${e.id}')" style="${cardBorder}">
|
|||
|
|
<div class="tx-card-left">
|
|||
|
|
<div class="tx-card-nr">#${nr}</div>
|
|||
|
|
<div class="tx-card-src-dot${isBank?' bank':''}"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="tx-card-body">
|
|||
|
|
<div class="tx-card-desc">${esc(e.description||'—')}</div>
|
|||
|
|
<div class="tx-card-meta">
|
|||
|
|
<span>${dFull}</span>
|
|||
|
|
${accts?`<span class="tx-card-accts">${esc(accts)}</span>`:''}
|
|||
|
|
${rcptBadge(e)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="tx-card-right">
|
|||
|
|
<div class="tx-card-amt">${amt>0?fmt(amt):'—'}</div>
|
|||
|
|
<div class="tx-card-status" style="color:${stColor}">${stText}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>`;
|
|||
|
|
}).join('');
|
|||
|
|
updatePager();
|
|||
|
|
}catch(err){
|
|||
|
|
tbody.innerHTML=`<tr><td colspan="7" class="tx-empty">⚠️ Fel: ${err.message}</td></tr>`;
|
|||
|
|
tcards.innerHTML=`<div class="tx-card-empty">⚠️ Fel: ${err.message}</div>`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function updatePager(){
|
|||
|
|
const pages=Math.ceil(S.txTotal/TX_LIMIT)||1;
|
|||
|
|
const start=(S.txPage-1)*TX_LIMIT+1;
|
|||
|
|
const end=Math.min(S.txPage*TX_LIMIT,S.txTotal);
|
|||
|
|
document.getElementById('pg-info').textContent=`Visar ${start}–${end} av ${S.txTotal}`;
|
|||
|
|
document.getElementById('pg-prev').disabled=S.txPage<=1;
|
|||
|
|
document.getElementById('pg-next').disabled=S.txPage>=pages;
|
|||
|
|
}
|
|||
|
|
function txPage(d){txLoad(S.txPage+d);}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// ENTRY DETAIL MODAL
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// Cache hämtade entries
|
|||
|
|
const entryCache={};
|
|||
|
|
async function openEntryModal(id){
|
|||
|
|
if(!id) return;
|
|||
|
|
// Kolla om vi har den i journal-state
|
|||
|
|
let e=S.journal.find(j=>j.id===id);
|
|||
|
|
if(!e&&entryCache[id]) e=entryCache[id];
|
|||
|
|
if(!e){
|
|||
|
|
// Hämta via search (vi har inget GET /journal/:id endpoint — använd filter)
|
|||
|
|
try{
|
|||
|
|
const r=await api(`/api/ledger/journal?fiscal_year=${FISCAL}&limit=1&offset=0`);
|
|||
|
|
// Fallback: visa direkt från DOM-data
|
|||
|
|
}catch(err){}
|
|||
|
|
}
|
|||
|
|
if(!e){openInfo('Verifikat','Detaljer ej tillgängliga för detta verifikat.');return;}
|
|||
|
|
entryCache[id]=e;
|
|||
|
|
const d=(e.entry_date||e.date||'').toString().slice(0,10);
|
|||
|
|
const nr=e.entry_number||'—';
|
|||
|
|
const lines=e.lines||[];
|
|||
|
|
const deb=lines.reduce((s,l)=>s+parseFloat(l.debit||0),0);
|
|||
|
|
const crd=lines.reduce((s,l)=>s+parseFloat(l.credit||0),0);
|
|||
|
|
const meta=e.metadata||{};
|
|||
|
|
let h=`<div style="margin-bottom:14px;padding:10px 12px;background:var(--navy-05);border-radius:7px">
|
|||
|
|
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:.8rem">
|
|||
|
|
<span style="color:var(--muted)">Datum:</span><strong>${d}</strong>
|
|||
|
|
<span style="color:var(--muted)">Verifikat:</span><strong>#${nr}</strong>
|
|||
|
|
<span style="color:var(--muted)">Status:</span><strong>${e.status||'—'}</strong>
|
|||
|
|
<span style="color:var(--muted)">Källa:</span><strong>${e.source_type||'—'}</strong>
|
|||
|
|
${e.reference?`<span style="color:var(--muted)">Referens:</span><strong>${esc(e.reference)}</strong>`:''}
|
|||
|
|
${e.user_id&&e.user_id!=='system'?`<span style="color:var(--muted)">Bokfört av:</span><strong>${esc(e.user_id)}</strong>`:''}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<table class="det-table">
|
|||
|
|
<thead><tr><th>Konto</th><th>Namn</th><th style="text-align:right">Debet</th><th style="text-align:right">Kredit</th></tr></thead>
|
|||
|
|
<tbody>`;
|
|||
|
|
lines.forEach(l=>{
|
|||
|
|
h+=`<tr><td style="font-family:monospace;font-size:.78rem">${l.account_number||''}</td>
|
|||
|
|
<td>${esc(l.account_name||'')}</td>
|
|||
|
|
<td style="text-align:right;font-variant-numeric:tabular-nums">${parseFloat(l.debit||0)>0?fmt(parseFloat(l.debit)):''}</td>
|
|||
|
|
<td style="text-align:right;font-variant-numeric:tabular-nums">${parseFloat(l.credit||0)>0?fmt(parseFloat(l.credit)):''}</td></tr>`;
|
|||
|
|
});
|
|||
|
|
h+=`</tbody><tfoot><tr class="det-total">
|
|||
|
|
<td colspan="2">SUMMA</td>
|
|||
|
|
<td style="text-align:right;font-variant-numeric:tabular-nums">${fmt(deb)}</td>
|
|||
|
|
<td style="text-align:right;font-variant-numeric:tabular-nums">${fmt(crd)}</td>
|
|||
|
|
</tr></tfoot></table>`;
|
|||
|
|
// Metadata
|
|||
|
|
if(Object.keys(meta).length>0){
|
|||
|
|
h+=`<div class="det-meta"><strong>Metadata:</strong><br>`;
|
|||
|
|
Object.entries(meta).forEach(([k,v])=>{h+=`<span style="display:inline-block;margin-right:10px;margin-top:3px"><strong>${esc(k)}:</strong> ${esc(typeof v==='object'?JSON.stringify(v):String(v))}</span>`;});
|
|||
|
|
h+=`</div>`;
|
|||
|
|
}
|
|||
|
|
const rcptCount = (e.metadata?.receipts || []).length;
|
|||
|
|
h+=`<div style="margin-top:10px;display:flex;gap:8px;align-items:center">
|
|||
|
|
<button onclick="closeModal('modal-info');openReceiptModal('${e.id}','${esc(e.description||'').replace(/'/g,"\'")}','${e.entry_number||'?'}')" class="act-btn primary" style="font-size:.8rem">
|
|||
|
|
📎 ${rcptCount > 0 ? rcptCount + ' kvitto' + (rcptCount>1?'n':'') : 'Lägg till kvitto'}
|
|||
|
|
</button>
|
|||
|
|
${rcptCount > 0 ? '<span style="font-size:.75rem;color:var(--green)">✓ Kvitton uppladdade</span>' : '<span style="font-size:.75rem;color:var(--muted)">Inget kvitto ännu</span>'}
|
|||
|
|
</div>`;
|
|||
|
|
openInfo(`Verifikat #${nr} — ${esc(e.description||'').slice(0,50)}`,h);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// FAKTUROR
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
async function loadInvoices(){
|
|||
|
|
const empty=document.getElementById('inv-empty');
|
|||
|
|
const table=document.getElementById('inv-table');
|
|||
|
|
empty.textContent='Laddar fakturor…';empty.style.display='';table.style.display='none';
|
|||
|
|
try{
|
|||
|
|
const r=await api(`/api/ledger/invoices?fiscal_year=${FISCAL}`);
|
|||
|
|
const invs=(r.invoices||[]).filter(i=>{
|
|||
|
|
const d=(i.description||'').toLowerCase();
|
|||
|
|
return !d.includes('växling')&&!d.includes('[revolut')&&(i.total||0)>=1000;
|
|||
|
|
});
|
|||
|
|
S.invoices=invs;
|
|||
|
|
const tot =invs.reduce((s,i)=>s+(i.total||0),0);
|
|||
|
|
const paid=invs.filter(i=>i.status==='paid').reduce((s,i)=>s+(i.total||0),0);
|
|||
|
|
const pend=invs.filter(i=>['sent','overdue'].includes(i.status)).reduce((s,i)=>s+(i.total||0),0);
|
|||
|
|
document.getElementById('inv-summary').innerHTML=`
|
|||
|
|
<div class="vat-card"><div class="vat-ruta">Totalt fakturerat</div><div class="vat-amt">${fmt(tot)}</div></div>
|
|||
|
|
<div class="vat-card"><div class="vat-ruta">Betalt</div><div class="vat-amt" style="color:var(--green)">${fmt(paid)}</div></div>
|
|||
|
|
<div class="vat-card"><div class="vat-ruta">Utestående</div><div class="vat-amt" style="${pend>0?'color:var(--amber)':'color:var(--muted)'}">${fmt(pend)}</div></div>`;
|
|||
|
|
if(!invs.length){empty.innerHTML='🧾 Inga fakturor ännu. Klicka "Ny faktura" för att börja.';return;}
|
|||
|
|
empty.style.display='none';table.style.display='';
|
|||
|
|
const sl={draft:'Utkast',sent:'Skickad',paid:'Betald',overdue:'Förfallen',posted:'Bokförd'};
|
|||
|
|
document.getElementById('inv-tbody').innerHTML=invs.map(i=>{
|
|||
|
|
const cls=i.status||'posted';
|
|||
|
|
const tot=i.total||i.totalInclVat||0;
|
|||
|
|
const exc=i.amount||(tot/1.25);
|
|||
|
|
const vat=i.vatAmount||(tot-exc);
|
|||
|
|
return`<tr onclick="openEntryModal('${i.id||''}')">
|
|||
|
|
<td><strong>#${i.entry_number||i.number||'—'}</strong><br><span style="font-size:.7rem;color:var(--muted)">${esc(i.reference||'')}</span></td>
|
|||
|
|
<td><strong>${esc(i.customer||'—')}</strong></td>
|
|||
|
|
<td style="white-space:nowrap">${i.date||'—'}</td>
|
|||
|
|
<td style="max-width:200px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis">${esc(i.description||'')}</td>
|
|||
|
|
<td style="text-align:right;font-variant-numeric:tabular-nums">${fmt(exc)}</td>
|
|||
|
|
<td style="text-align:right;font-variant-numeric:tabular-nums">${fmt(vat)}</td>
|
|||
|
|
<td style="text-align:right;font-weight:700;font-variant-numeric:tabular-nums">${fmt(tot)}</td>
|
|||
|
|
<td><span class="inv-status ${cls}">● ${sl[cls]||cls}</span></td>
|
|||
|
|
</tr>`;
|
|||
|
|
}).join('');
|
|||
|
|
}catch(err){empty.innerHTML=`⚠️ Fel: ${err.message}`;}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// MOMS
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
async function loadVAT(){
|
|||
|
|
const body=document.getElementById('vat-body');
|
|||
|
|
body.innerHTML='<div style="text-align:center;padding:20px;color:var(--muted)">Hämtar momsdata…</div>';
|
|||
|
|
const q=document.getElementById('vat-q-sel').value;
|
|||
|
|
const qi=parseInt(q[1])-1;
|
|||
|
|
const y=FISCAL;
|
|||
|
|
const from=`${y}-${String(qi*3+1).padStart(2,'0')}`;
|
|||
|
|
const to=`${y}-${String(qi*3+3).padStart(2,'0')}`;
|
|||
|
|
try{
|
|||
|
|
const r=await api(`/api/ledger/trial-balance?fiscal_year=${FISCAL}&period_from=${from}&period_to=${to}`);
|
|||
|
|
const accs=r.accounts||[];
|
|||
|
|
const g=(num,field)=>{const a=accs.find(x=>x.account_number===num);return a?Math.abs(parseFloat(a[field]||0)):0;};
|
|||
|
|
const out25 =g('2611','total_credit');
|
|||
|
|
const outOSS=g('2615','total_credit');
|
|||
|
|
const in2645=g('2645','total_debit');
|
|||
|
|
const rev =accs.filter(a=>/^3/.test(a.account_number)).reduce((s,a)=>s+Math.abs(fbal(a)),0);
|
|||
|
|
const netto =out25+outOSS-in2645;
|
|||
|
|
// Q1 moms obetald-varning (hårdkodad känd fakta från bokföringen)
|
|||
|
|
const q1warn=q==='Q1'||q==='Q2';
|
|||
|
|
let h='';
|
|||
|
|
if(q1warn&&q==='Q2'){
|
|||
|
|
h+=`<div class="vat-alert warn">⚠️ <strong>OBS: Q1-moms 202 787 kr är obetald</strong> — förfallen till Skatteverket. Åtgärda omgående.</div>`;
|
|||
|
|
}
|
|||
|
|
const rutor=[
|
|||
|
|
{r:'05',lbl:'Momspliktig omsättning (exkl. moms)',amt:rev,note:'Konton 3000/3010/3020'},
|
|||
|
|
{r:'10',lbl:'Utgående moms 25%',amt:out25,note:'Konto 2611'},
|
|||
|
|
{r:'20',lbl:'Utgående moms omvänd skattskyldighet',amt:outOSS,note:'Konto 2615'},
|
|||
|
|
{r:'30',lbl:'Ingående moms avdragsgill',amt:in2645,note:'Konto 2645'},
|
|||
|
|
];
|
|||
|
|
h+=`<div class="vat-grid">`;
|
|||
|
|
rutor.forEach(rv=>{
|
|||
|
|
h+=`<div class="vat-card">
|
|||
|
|
<div class="vat-ruta">Ruta ${rv.r}</div>
|
|||
|
|
<div class="vat-lbl">${rv.lbl}</div>
|
|||
|
|
<div class="vat-amt" ${!rv.amt?'style="color:var(--muted)"':''}>${fmt(rv.amt)}</div>
|
|||
|
|
<div class="vat-note">${rv.note}</div>
|
|||
|
|
</div>`;
|
|||
|
|
});
|
|||
|
|
h+=`</div>`;
|
|||
|
|
const pay=netto>=0;
|
|||
|
|
h+=`<div class="vat-total-row">
|
|||
|
|
<div>
|
|||
|
|
<div class="vat-total-lbl">Ruta 49 — Moms att ${pay?'betala':'återfå'}</div>
|
|||
|
|
<div style="font-size:.75rem;opacity:.65;margin-top:2px">${q} ${FISCAL} · Beräknat från kontobalanser</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="vat-total-amt">${pay?'+':'−'}${fmt(Math.abs(netto))}</div>
|
|||
|
|
</div>`;
|
|||
|
|
h+=`<div style="font-size:.75rem;color:var(--muted);margin-top:4px">⚠️ Indikativt underlag. Verifiera mot bokförd data och kontakta KPMG (Andreas Vretblom) innan inlämning.</div>`;
|
|||
|
|
body.innerHTML=h;
|
|||
|
|
}catch(err){body.innerHTML=`<div style="color:var(--red);padding:20px">⚠️ Fel: ${err.message}</div>`;}
|
|||
|
|
}
|
|||
|
|
function exportVAT(){
|
|||
|
|
const q=document.getElementById('vat-q-sel').value;
|
|||
|
|
const a=document.createElement('a');
|
|||
|
|
a.href=URL.createObjectURL(new Blob([`Momsunderlag LandveX AB\n${q} ${FISCAL}\nKontakta KPMG: andreas.vretblom@kpmg.se`],{type:'text/plain;charset=utf-8'}));
|
|||
|
|
a.download=`moms-${q}-${FISCAL}.txt`;a.click();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// PERIODSTÄNGNING
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// ─────────────────────────────────────────────────────────────
|
|||
|
|
async function loadPeriod(){
|
|||
|
|
const period=S.period||curPeriod();
|
|||
|
|
document.getElementById('pc-label').textContent=pLabel(period);
|
|||
|
|
let tbData=S.tb,status='open';
|
|||
|
|
try{
|
|||
|
|
const[pR,tbR]=await Promise.all([
|
|||
|
|
api(`/api/ledger/periods/${period}/status`),
|
|||
|
|
api(`/api/ledger/trial-balance?fiscal_year=${FISCAL}&period=${period}`),
|
|||
|
|
]);
|
|||
|
|
tbData=tbR.accounts||[];
|
|||
|
|
status=pR.period?.status||'open';
|
|||
|
|
}catch{}
|
|||
|
|
const badge=document.getElementById('pc-badge');
|
|||
|
|
const lbl={open:'Öppen',review:'Under granskning',approved:'Godkänd',closed:'Stängd'};
|
|||
|
|
badge.className=`pbadge ${status}`;badge.textContent=`● ${lbl[status]||status}`;
|
|||
|
|
// Workflow steps
|
|||
|
|
const steps=['open','review','approved','closed'];
|
|||
|
|
const idx=steps.indexOf(status);
|
|||
|
|
steps.forEach((s,i)=>{
|
|||
|
|
const el=document.getElementById('wf-'+s);
|
|||
|
|
el.className='wf-step'+(i<idx?' done':i===idx?' active':'');
|
|||
|
|
});
|
|||
|
|
// Checklist live
|
|||
|
|
const getTB=num=>{const a=tbData.find(x=>x.account_number===num);return a?parseFloat(a.balance||0):0;};
|
|||
|
|
const td=tbData.reduce((s,a)=>s+fdeb(a),0);
|
|||
|
|
const tc=tbData.reduce((s,a)=>s+fcrd(a),0);
|
|||
|
|
const balOK=tbData.length>0&&Math.abs(td-tc)<1;
|
|||
|
|
const obsOK=Math.abs(getTB('1689'))<1;
|
|||
|
|
const vatOK=Math.abs(getTB('2611'))>0||tbData.length===0;
|
|||
|
|
const draftN=S.journal.filter(e=>e.status==='draft').length;
|
|||
|
|
const dftOK=draftN===0;
|
|||
|
|
function setChk(ciId,cvId,ok,val){
|
|||
|
|
const ic=document.getElementById(ciId);ic.className=`chk-icon ${ok?'ok':'warn'}`;ic.textContent=ok?'✅':'⚠️';
|
|||
|
|
document.getElementById(cvId).textContent=val;
|
|||
|
|
}
|
|||
|
|
setChk('ci-bal','cv-bal',balOK,balOK?'✓ Balanserar':`Diff: ${fmt(Math.abs(td-tc))}`);
|
|||
|
|
setChk('ci-obs','cv-obs',obsOK,obsOK?'✓ 0 kr':`${fmt(getTB('1689'))} kvar`);
|
|||
|
|
setChk('ci-vat','cv-vat',vatOK,vatOK?`${fmt(Math.abs(getTB('2611')))}`:'Kontrollera');
|
|||
|
|
setChk('ci-dft','cv-dft',dftOK,dftOK?'✓ Inga utkast':`${draftN} utkast`);
|
|||
|
|
const allOK=balOK&&obsOK&&dftOK;
|
|||
|
|
const am={open:{a:'submit-review',lbl:'📤 Skicka för granskning'},review:{a:'approve',lbl:'✅ Godkänn'},approved:{a:'close',lbl:'🔒 Stäng period'},closed:{a:'reopen',lbl:'🔓 Öppna igen'}};
|
|||
|
|
const act=am[status];
|
|||
|
|
const div=document.getElementById('pc-actions');div.innerHTML='';
|
|||
|
|
if(act){
|
|||
|
|
const dis=act.a==='close'&&!allOK?'disabled':'';
|
|||
|
|
div.innerHTML=`<button class="btn-top" ${dis} onclick="doPeriodAct('${act.a}','${period}')">${act.lbl}</button>`;
|
|||
|
|
if(act.a==='close'&&!allOK) div.innerHTML+=`<span style="font-size:.78rem;color:var(--red);margin-left:8px">⚠️ Åtgärda checklist</span>`;
|
|||
|
|
}
|
|||
|
|
if(tbData.length>0){
|
|||
|
|
div.innerHTML+=`<button class="btn-outline" onclick="showSnapshot()" style="margin-left:4px">📸 Trial balance</button>`;
|
|||
|
|
}
|
|||
|
|
// Spara tbData för snapshot
|
|||
|
|
window._pcTB=tbData;
|
|||
|
|
}
|
|||
|
|
function showSnapshot(){
|
|||
|
|
const tb=window._pcTB||[];
|
|||
|
|
if(!tb.length){openInfo('Trial Balance','Ingen data');return;}
|
|||
|
|
let h=`<table class="det-table"><thead><tr><th>Konto</th><th>Namn</th><th style="text-align:right">Debet</th><th style="text-align:right">Kredit</th><th style="text-align:right">Saldo</th></tr></thead><tbody>`;
|
|||
|
|
tb.forEach(a=>{
|
|||
|
|
const b=fbal(a);
|
|||
|
|
h+=`<tr><td style="font-family:monospace;font-size:.78rem">${a.account_number}</td><td>${esc(acname(a))}</td><td style="text-align:right;font-variant-numeric:tabular-nums">${fmt(fdeb(a))}</td><td style="text-align:right;font-variant-numeric:tabular-nums">${fmt(fcrd(a))}</td><td style="text-align:right;font-weight:700;font-variant-numeric:tabular-nums">${fmt(b)}</td></tr>`;
|
|||
|
|
});
|
|||
|
|
const td=tb.reduce((s,a)=>s+fdeb(a),0);
|
|||
|
|
const tc=tb.reduce((s,a)=>s+fcrd(a),0);
|
|||
|
|
h+=`<tr class="det-total"><td colspan="2">SUMMA</td><td style="text-align:right">${fmt(td)}</td><td style="text-align:right">${fmt(tc)}</td><td style="text-align:right;${Math.abs(td-tc)<1?'color:var(--green)':'color:var(--red)'}">${fmt(td-tc)}</td></tr></tbody></table>`;
|
|||
|
|
openInfo(`Trial Balance — ${pLabel(S.period)}`,h);
|
|||
|
|
}
|
|||
|
|
async function doPeriodAct(action,period){
|
|||
|
|
const btn=document.querySelector('#pc-actions button');
|
|||
|
|
if(btn){btn.disabled=true;btn.textContent='⏳…';}
|
|||
|
|
const fb=document.getElementById('pc-fb');
|
|||
|
|
try{
|
|||
|
|
const r=await api(`/api/ledger/periods/${period}/${action}`,'POST',{fiscal_year:FISCAL});
|
|||
|
|
fb.innerHTML=`<span style="color:var(--green)">✅ ${r.message||'Uppdaterat'}</span>`;
|
|||
|
|
await loadAll();loadPeriod();
|
|||
|
|
}catch(e){fb.innerHTML=`<span style="color:var(--red)">❌ ${e.message}</span>`;}
|
|||
|
|
if(btn)btn.disabled=false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// NY VERIFIKATION
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
let lineN=0,allAccounts=[];
|
|||
|
|
function openNewEntry(){
|
|||
|
|
// Säkra att transaktions-fliken är aktiv först
|
|||
|
|
if(!document.getElementById('tab-transactions').classList.contains('active')){
|
|||
|
|
switchTab('transactions');
|
|||
|
|
}
|
|||
|
|
document.getElementById('entry-body').classList.add('open');
|
|||
|
|
document.getElementById('entry-chev').style.transform='rotate(180deg)';
|
|||
|
|
if(!lineN){addLine();addLine();}
|
|||
|
|
// Scrolla efter att fliken hunnit bli synlig
|
|||
|
|
requestAnimationFrame(()=>{
|
|||
|
|
requestAnimationFrame(()=>{
|
|||
|
|
document.getElementById('new-entry-card').scrollIntoView({behavior:'smooth',block:'start'});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function toggleEntry(){
|
|||
|
|
document.getElementById('entry-body').classList.toggle('open');
|
|||
|
|
const open=document.getElementById('entry-body').classList.contains('open');
|
|||
|
|
document.getElementById('entry-chev').style.transform=open?'rotate(180deg)':'none';
|
|||
|
|
}
|
|||
|
|
function resetEntry(){
|
|||
|
|
document.getElementById('e-date').value=today();
|
|||
|
|
document.getElementById('e-desc').value='';document.getElementById('e-ref').value='';
|
|||
|
|
document.getElementById('e-lines').innerHTML='';lineN=0;addLine();addLine();updateBal();
|
|||
|
|
const fb=document.getElementById('e-fb');fb.className='feedback';fb.textContent='';
|
|||
|
|
}
|
|||
|
|
function addLine(){
|
|||
|
|
const id=++lineN;
|
|||
|
|
const div=document.createElement('div');div.className='e-line';div.id=`ln-${id}`;
|
|||
|
|
div.innerHTML=`<div class="acc-wrap">
|
|||
|
|
<input type="text" class="acc-in" id="ac-${id}" placeholder="Sök konto…" autocomplete="off"
|
|||
|
|
oninput="filterAcc(${id})" onfocus="showAcc(${id})" onblur="hideAcc(${id})">
|
|||
|
|
<div class="acc-drop" id="dr-${id}"></div>
|
|||
|
|
</div>
|
|||
|
|
<input type="number" class="num-in" id="db-${id}" placeholder="0" min="0" step="0.01" oninput="updateBal()">
|
|||
|
|
<input type="number" class="num-in" id="cr-${id}" placeholder="0" min="0" step="0.01" oninput="updateBal()">
|
|||
|
|
<button class="rm-btn" onclick="rmLine(${id})">✕</button>`;
|
|||
|
|
document.getElementById('e-lines').appendChild(div);updateBal();
|
|||
|
|
}
|
|||
|
|
function rmLine(id){document.getElementById(`ln-${id}`)?.remove();updateBal();}
|
|||
|
|
function filterAcc(id){
|
|||
|
|
const q=document.getElementById(`ac-${id}`).value.toLowerCase();
|
|||
|
|
const dr=document.getElementById(`dr-${id}`);
|
|||
|
|
const matches=allAccounts.filter(a=>a.account_number.includes(q)||(acname(a)).toLowerCase().includes(q)).slice(0,20);
|
|||
|
|
dr.innerHTML=matches.length
|
|||
|
|
?matches.map(a=>`<div class="acc-opt" onmousedown="pickAcc(${id},'${a.account_number}','${esc(acname(a)).replace(/'/g,"\\'")}')"><span class="acc-code">${a.account_number}</span><span>${esc(acname(a))}</span></div>`).join('')
|
|||
|
|
:`<div class="acc-opt" style="color:var(--muted);cursor:default">Inga konton</div>`;
|
|||
|
|
dr.classList.add('open');
|
|||
|
|
}
|
|||
|
|
function showAcc(id){filterAcc(id);}
|
|||
|
|
function hideAcc(id){setTimeout(()=>document.getElementById(`dr-${id}`)?.classList.remove('open'),180);}
|
|||
|
|
function pickAcc(id,code,name){
|
|||
|
|
const inp=document.getElementById(`ac-${id}`);
|
|||
|
|
inp.value=`${code} – ${name}`;inp.dataset.num=code;
|
|||
|
|
document.getElementById(`dr-${id}`)?.classList.remove('open');
|
|||
|
|
}
|
|||
|
|
function updateBal(){
|
|||
|
|
let d=0,c=0;
|
|||
|
|
document.querySelectorAll('.e-line').forEach(ln=>{
|
|||
|
|
const id=ln.id.replace('ln-','');
|
|||
|
|
d+=parseFloat(document.getElementById(`db-${id}`)?.value||0)||0;
|
|||
|
|
c+=parseFloat(document.getElementById(`cr-${id}`)?.value||0)||0;
|
|||
|
|
});
|
|||
|
|
const ind=document.getElementById('bal-ind');
|
|||
|
|
document.getElementById('bal-d').textContent=fmt(d);
|
|||
|
|
document.getElementById('bal-k').textContent=fmt(c);
|
|||
|
|
const ok=d>0&&Math.abs(d-c)<0.01;
|
|||
|
|
ind.className='bal-ind'+(ok?' ok':(d||c)?' bad':'');
|
|||
|
|
document.getElementById('bal-txt').textContent=ok?'✅ Balanserad':(d||c)?`⚠️ Diff: ${fmt(Math.abs(d-c))}`:'Ange rader';
|
|||
|
|
}
|
|||
|
|
async function saveEntry(){
|
|||
|
|
const date=document.getElementById('e-date').value;
|
|||
|
|
const desc=document.getElementById('e-desc').value.trim();
|
|||
|
|
const ref =document.getElementById('e-ref').value.trim();
|
|||
|
|
const fb=document.getElementById('e-fb');
|
|||
|
|
if(!date||!desc){fb.textContent='⚠️ Ange datum och beskrivning';fb.className='feedback err';return;}
|
|||
|
|
const lines=[];let dbt=0,crt=0;
|
|||
|
|
document.querySelectorAll('.e-line').forEach(ln=>{
|
|||
|
|
const id=ln.id.replace('ln-','');
|
|||
|
|
const inp=document.getElementById(`ac-${id}`);
|
|||
|
|
const num=inp?.dataset.num||inp?.value.split(' ')[0];
|
|||
|
|
const d=parseFloat(document.getElementById(`db-${id}`)?.value||0)||0;
|
|||
|
|
const c=parseFloat(document.getElementById(`cr-${id}`)?.value||0)||0;
|
|||
|
|
if(num&&(d||c)){lines.push({account_number:num,debit:d,credit:c});dbt+=d;crt+=c;}
|
|||
|
|
});
|
|||
|
|
if(lines.length<2){fb.textContent='⚠️ Minst 2 rader';fb.className='feedback err';return;}
|
|||
|
|
if(Math.abs(dbt-crt)>0.01){fb.textContent=`⚠️ Diff: ${fmt(Math.abs(dbt-crt))}`;fb.className='feedback err';return;}
|
|||
|
|
const btn=document.getElementById('save-btn');btn.disabled=true;btn.textContent='⏳ Sparar…';fb.className='feedback';
|
|||
|
|
try{
|
|||
|
|
const r=await api('/api/ledger/journal','POST',{date,description:desc,reference:ref||undefined,lines,fiscal_year:FISCAL,period:S.period});
|
|||
|
|
const eid=r.id||r.entry_id||'?';
|
|||
|
|
try{await api(`/api/ledger/journal/${eid}/post`,'POST',{});}catch{}
|
|||
|
|
fb.textContent=`✅ Verifikat #${eid.toString().slice(0,8)} sparat`;fb.className='feedback ok';
|
|||
|
|
resetEntry();await loadAll();
|
|||
|
|
}catch(e){fb.textContent=`❌ ${e.message}`;fb.className='feedback err';}
|
|||
|
|
btn.disabled=false;btn.textContent='💾 Spara';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// NY FAKTURA
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
function openNewInvoice(){
|
|||
|
|
document.getElementById('inv-date').value=today();
|
|||
|
|
const due=new Date();due.setDate(due.getDate()+30);
|
|||
|
|
document.getElementById('inv-due').value=due.toISOString().slice(0,10);
|
|||
|
|
['inv-cust','inv-amt','inv-ref','inv-desc'].forEach(id=>document.getElementById(id).value='');
|
|||
|
|
document.getElementById('inv-vat').value='25';
|
|||
|
|
document.getElementById('inv-fb').className='feedback';
|
|||
|
|
document.getElementById('inv-preview').style.display='none';
|
|||
|
|
openModal('modal-inv');
|
|||
|
|
}
|
|||
|
|
function previewInv(){
|
|||
|
|
const amt=parseFloat(document.getElementById('inv-amt').value)||0;
|
|||
|
|
const rate=parseInt(document.getElementById('inv-vat').value)||25;
|
|||
|
|
const prev=document.getElementById('inv-preview');
|
|||
|
|
if(amt>0){prev.style.display='';prev.innerHTML=`${fmt(amt)} + ${fmt(amt*rate/100)} moms (${rate}%) = <strong>${fmt(amt+amt*rate/100)}</strong> totalt`;}
|
|||
|
|
else prev.style.display='none';
|
|||
|
|
}
|
|||
|
|
async function saveInvoice(){
|
|||
|
|
const cust=document.getElementById('inv-cust').value.trim();
|
|||
|
|
const date=document.getElementById('inv-date').value;
|
|||
|
|
const due =document.getElementById('inv-due').value;
|
|||
|
|
const amt =parseFloat(document.getElementById('inv-amt').value)||0;
|
|||
|
|
const rate=parseInt(document.getElementById('inv-vat').value)||25;
|
|||
|
|
const ref =document.getElementById('inv-ref').value.trim();
|
|||
|
|
const desc=document.getElementById('inv-desc').value.trim();
|
|||
|
|
const fb=document.getElementById('inv-fb');
|
|||
|
|
if(!cust){fb.textContent='⚠️ Ange kund';fb.className='feedback err';return;}
|
|||
|
|
if(amt<=0){fb.textContent='⚠️ Ange belopp';fb.className='feedback err';return;}
|
|||
|
|
const vat=parseFloat((amt*rate/100).toFixed(2));
|
|||
|
|
const tot=amt+vat;
|
|||
|
|
const num=`FAK-${FISCAL}-${String((S.invoices?.length||0)+1).padStart(3,'0')}`;
|
|||
|
|
const va=rate===25?'2611':rate===12?'2615':rate===6?'2616':null;
|
|||
|
|
const ra=rate===25?'3010':rate===12?'3020':'3000';
|
|||
|
|
const lines=[
|
|||
|
|
{account_number:'1510',debit:tot,credit:0},
|
|||
|
|
{account_number:ra,debit:0,credit:amt},
|
|||
|
|
...(vat>0&&va?[{account_number:va,debit:0,credit:vat}]:[]),
|
|||
|
|
];
|
|||
|
|
const btn=document.getElementById('inv-save');btn.disabled=true;btn.textContent='⏳…';
|
|||
|
|
try{
|
|||
|
|
const r=await api('/api/ledger/journal','POST',{date,description:`${num} – ${desc||cust}`,reference:num,lines,fiscal_year:FISCAL,period:S.period});
|
|||
|
|
const eid=r.id||r.entry_id;
|
|||
|
|
if(eid){try{await api(`/api/ledger/journal/${eid}/post`,'POST',{});}catch{}}
|
|||
|
|
fb.textContent=`✅ ${num} skapad`;fb.className='feedback ok';
|
|||
|
|
await loadInvoices();
|
|||
|
|
setTimeout(()=>closeModal('modal-inv'),1500);
|
|||
|
|
}catch(e){fb.textContent=`❌ ${e.message}`;fb.className='feedback err';}
|
|||
|
|
btn.disabled=false;btn.textContent='💾 Spara & bokför';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// SIE4
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
function doSIE(){
|
|||
|
|
fetch(`/api/ledger/export/sie4?fiscal_year=${FISCAL}`,{headers:{'x-internal-source':'ouroboros-system','x-tenant-id':TENANT}})
|
|||
|
|
.then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.blob();})
|
|||
|
|
.then(b=>{const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=`landvex-${FISCAL}.se`;a.click();})
|
|||
|
|
.catch(e=>openInfo('SIE4 Export',`<div style="color:var(--red)">⚠️ Export misslyckades: ${e.message}</div>`));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// MODAL
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
function openModal(id){document.getElementById(id)?.classList.remove('hidden');}
|
|||
|
|
function closeModal(id){document.getElementById(id)?.classList.add('hidden');}
|
|||
|
|
function openInfo(title,html){
|
|||
|
|
document.getElementById('mi-title').textContent=title;
|
|||
|
|
document.getElementById('mi-body').innerHTML=html;
|
|||
|
|
openModal('modal-info');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// INIT
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// PROGNOS
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
async function loadForecast(){
|
|||
|
|
// Hämta YTD trial balance
|
|||
|
|
let tb = S.tb;
|
|||
|
|
if(!tb.length){
|
|||
|
|
try{ const r=await api('/api/ledger/trial-balance?fiscal_year=2026'); tb=r.accounts||[]; }catch{}
|
|||
|
|
}
|
|||
|
|
const get = (num,field) => { const a=tb.find(x=>x.account_number===num); return a?Math.abs(parseFloat(a[field]||0)):0; };
|
|||
|
|
const getbal = num => { const a=tb.find(x=>x.account_number===num); return a?parseFloat(a.balance||0):0; };
|
|||
|
|
|
|||
|
|
// === NULÄGE ===
|
|||
|
|
const kassa = tb.filter(a=>/^19/.test(a.account_number)).reduce((s,a)=>s+parseFloat(a.balance||0),0);
|
|||
|
|
// Skulder: 2611 (moms ut), 2650 (momsredovisning), 2710 (personalskatt)
|
|||
|
|
const skuld2611 = Math.max(0, -getbal('2611')); // kredit = skuld
|
|||
|
|
const skuld2650 = Math.max(0, -getbal('2650'));
|
|||
|
|
const skuld2710 = Math.max(0, -getbal('2710'));
|
|||
|
|
const skulder = skuld2611 + skuld2650 + skuld2710;
|
|||
|
|
const kasseEfter = kassa - skulder;
|
|||
|
|
|
|||
|
|
// Burn rate: genomsnitt kostnader per månad (6 månader)
|
|||
|
|
const totKostnader = tb.filter(a=>/^[4-8]/.test(a.account_number)).reduce((s,a)=>s+Math.abs(parseFloat(a.balance||0)),0);
|
|||
|
|
const burnPerMan = totKostnader / 6;
|
|||
|
|
|
|||
|
|
// Bekräftad intäkt per månad: Trygg Bil kontrakt = 222916 kr/mån
|
|||
|
|
const TRYGGBIL_MAN = 222916;
|
|||
|
|
// Runway i månader
|
|||
|
|
const netto_man = TRYGGBIL_MAN - burnPerMan;
|
|||
|
|
let runway;
|
|||
|
|
if(netto_man > 0) runway = 'Positiv kassaflöde';
|
|||
|
|
else runway = Math.max(0, Math.floor(kasseEfter / Math.abs(netto_man))) + ' mån runway';
|
|||
|
|
|
|||
|
|
// KPI-kort
|
|||
|
|
document.getElementById('fc-kassa-v').textContent = fmt(kassa);
|
|||
|
|
document.getElementById('fc-skulder-v').textContent = fmt(skulder);
|
|||
|
|
const nettoEl = document.getElementById('fc-netto-v');
|
|||
|
|
nettoEl.textContent = fmt(kasseEfter);
|
|||
|
|
nettoEl.className = 'kpi-val ' + (kasseEfter > 50000 ? 'green' : kasseEfter > 0 ? 'amber' : 'red');
|
|||
|
|
document.getElementById('fc-netto-card').className = 'kpi ' + (kasseEfter > 50000 ? 'green' : kasseEfter > 0 ? 'amber' : 'red');
|
|||
|
|
document.getElementById('fc-burn-v').textContent = fmt(burnPerMan) + '/mån';
|
|||
|
|
document.getElementById('fc-runway').textContent = runway;
|
|||
|
|
|
|||
|
|
// Alert om kritiskt läge
|
|||
|
|
const alertEl = document.getElementById('fc-alert');
|
|||
|
|
if(kasseEfter < 50000){
|
|||
|
|
alertEl.style.display='';
|
|||
|
|
alertEl.style.background='rgba(229,62,62,.08)';
|
|||
|
|
alertEl.style.border='1.5px solid rgba(229,62,62,.3)';
|
|||
|
|
alertEl.style.color='var(--red)';
|
|||
|
|
alertEl.innerHTML = '🚨 <strong>KRITISKT:</strong> Kassa efter förfallna skulder är under 50 000 kr. Omedelbar åtgärd krävs.';
|
|||
|
|
} else if(kasseEfter < 200000){
|
|||
|
|
alertEl.style.display='';
|
|||
|
|
alertEl.style.background='rgba(200,118,58,.08)';
|
|||
|
|
alertEl.style.border='1.5px solid rgba(200,118,58,.3)';
|
|||
|
|
alertEl.style.color='var(--amber)';
|
|||
|
|
alertEl.innerHTML = '⚠️ <strong>VARNING:</strong> Låg likviditet. Betala skulder och säkra nästa intäkt.';
|
|||
|
|
} else {
|
|||
|
|
alertEl.style.display='none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// === PROGNOS 6 månader ===
|
|||
|
|
const now = new Date();
|
|||
|
|
const curY = now.getFullYear(), curM = now.getMonth(); // 0-indexed
|
|||
|
|
const MN_SV = ['Jan','Feb','Mar','Apr','Maj','Jun','Jul','Aug','Sep','Okt','Nov','Dec'];
|
|||
|
|
let kassaLopande = kasseEfter; // börja från kassa efter skulder
|
|||
|
|
|
|||
|
|
const rows = [];
|
|||
|
|
// Månad 0 = denna månad (delvis), resterande = framtida
|
|||
|
|
for(let i=1; i<=6; i++){
|
|||
|
|
const mIdx = (curM + i) % 12;
|
|||
|
|
const yr = curY + Math.floor((curM + i) / 12);
|
|||
|
|
const lbl = MN_SV[mIdx] + ' ' + yr;
|
|||
|
|
|
|||
|
|
// Inbetalningar: Trygg Bil (222 916/mån) + marginal för övrigt (10%)
|
|||
|
|
const inn = TRYGGBIL_MAN * 1.0;
|
|||
|
|
// Utbetalningar: burn + extra för moms Q kvartalsslut
|
|||
|
|
let ut = burnPerMan;
|
|||
|
|
// Lägg på moms vid kvartalsskiften (apr, jul, okt, jan)
|
|||
|
|
if([0,3,6,9].includes(mIdx)) ut += 110000; // ungefär moms var 3:e mån
|
|||
|
|
|
|||
|
|
const netto = inn - ut;
|
|||
|
|
kassaLopande += netto;
|
|||
|
|
rows.push({ lbl, inn, ut, netto, kassa: kassaLopande });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Rita stapeldiagram
|
|||
|
|
const maxAbs = Math.max(...rows.map(r=>Math.abs(r.kassa)), 1);
|
|||
|
|
const chartEl = document.getElementById('fc-barchart');
|
|||
|
|
const lblEl = document.getElementById('fc-barlabels');
|
|||
|
|
chartEl.innerHTML=''; lblEl.innerHTML='';
|
|||
|
|
rows.forEach(r=>{
|
|||
|
|
const pct = Math.min(100, Math.abs(r.kassa) / maxAbs * 100);
|
|||
|
|
const cls = r.kassa > 100000 ? 'pos' : r.kassa > 0 ? 'warn' : 'neg';
|
|||
|
|
chartEl.innerHTML += `<div class="fc-bar-col"><div class="fc-bar ${cls}" style="height:${Math.max(3,pct*1.5)}px"></div><div class="fc-bv">${Math.round(r.kassa/1000)}k</div></div>`;
|
|||
|
|
lblEl.innerHTML += `<div style="flex:1;text-align:center;font-size:.68rem">${r.lbl}</div>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Tabell
|
|||
|
|
const tbody = document.getElementById('fc-table-body');
|
|||
|
|
tbody.innerHTML = rows.map((r,i)=>{
|
|||
|
|
const kassaCls = r.kassa>100000?'color:var(--green)':r.kassa>0?'color:var(--amber)':'color:var(--red)';
|
|||
|
|
return `<tr style="border-bottom:1px solid var(--border)${i===0?';background:var(--amber-bg)':''}">
|
|||
|
|
<td style="padding:6px 8px;font-weight:${i===0?'700':'400'}">${r.lbl}${i===0?' ←':''} </td>
|
|||
|
|
<td style="padding:6px 8px;text-align:right;color:var(--green);font-variant-numeric:tabular-nums">${fmt(r.inn)}</td>
|
|||
|
|
<td style="padding:6px 8px;text-align:right;color:var(--red);font-variant-numeric:tabular-nums">${fmt(r.ut)}</td>
|
|||
|
|
<td style="padding:6px 8px;text-align:right;font-weight:600;font-variant-numeric:tabular-nums;${r.netto>=0?'color:var(--green)':'color:var(--red)'}">${r.netto>=0?'+':''}${fmt(r.netto)}</td>
|
|||
|
|
<td style="padding:6px 8px;text-align:right;font-weight:700;font-variant-numeric:tabular-nums;${kassaCls}">${fmt(r.kassa)}</td>
|
|||
|
|
</tr>`;
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
// === KRITISKA ÅTGÄRDER ===
|
|||
|
|
const actions = [];
|
|||
|
|
if(skulder > 0) actions.push({dot:'r', text:`Betala förfallna skulder: <strong>${fmt(skulder)}</strong> (moms Q1/Q2 + personalskatt). Undvik dröjsmålsränta och skattetillägg.`});
|
|||
|
|
if(kasseEfter < 100000) actions.push({dot:'r', text:`Kritisk likviditet: <strong>${fmt(kasseEfter)}</strong> kvar efter skulder. Kontakta Trygg Bil för förskottsbetalning eller dragning på kredit.`});
|
|||
|
|
actions.push({dot:'a', text:`IT-kostnader <strong>${fmt(get('6230','balance'))}</strong> YTD (Lovable + AI). Störst enskild kostnad. Granska prenumerationer.`});
|
|||
|
|
actions.push({dot:'a', text:`Resekostnader <strong>${fmt(get('5800','balance') + get('5801','balance'))}</strong> YTD. Sätt tak på 50 000 kr/kvartal.`});
|
|||
|
|
if(get('1610','balance') > 0) actions.push({dot:'r', text:`Fordran anställd (1610): <strong>${fmt(get('1610','balance'))}</strong> — Winston överbetald lön. Driva in eller avskriv.`});
|
|||
|
|
actions.push({dot:'g', text:`Trygg Bil-kontrakt bekräftat t.o.m. mars 2027. Säkrad intäkt 222 916 kr/mån = <strong>2 675 000 kr/år</strong>.`});
|
|||
|
|
|
|||
|
|
document.getElementById('fc-actions').innerHTML = actions.map(a=>
|
|||
|
|
`<div class="act-row"><div class="act-dot ${a.dot}"></div><div>${a.text}</div></div>`
|
|||
|
|
).join('');
|
|||
|
|
|
|||
|
|
// === BUDGETREKOMMENDATIONER ===
|
|||
|
|
const recs = [
|
|||
|
|
{cls:'crit', text:'<strong>Betala moms omgående.</strong> 332 826 kr (2650) är förfallen. Varje dag kostar 1,25% ränta + potentiellt skattetillägg.'},
|
|||
|
|
{cls:'crit', text:'<strong>Sänk IT-budgeten.</strong> 450k YTD på Lovable = 75k/mån. Budgettak: 40k/mån. Spara 35k/mån = 420k/år.'},
|
|||
|
|
{cls:'warn', text:'<strong>Resebudget.</strong> 288k YTD resor = 48k/mån. Sätt tak 30k/mån, kräv förhandsgodkännande >5k.'},
|
|||
|
|
{cls:'warn', text:'<strong>Representation.</strong> 80k YTD ej avdragsgill (6072). Noll skattefördel. Sätt nollbudget tills lönsamhet är stabil.'},
|
|||
|
|
{cls:'info', text:'<strong>Nästa intäkt.</strong> En ny kund à 100k/mån ger positiv kassatäckning med marginal. Prioritera Fas 1 quiXzoom-kunder.'},
|
|||
|
|
{cls:'info', text:'<strong>USD-kassa 126k kr</strong> på Revolut USD. Konvertera till SEK för att täcka momsskuld om Nordea inte räcker.'},
|
|||
|
|
];
|
|||
|
|
document.getElementById('fc-recs').innerHTML = recs.map(r=>`<div class="rec ${r.cls}">${r.text}</div>`).join('');
|
|||
|
|
|
|||
|
|
// === INTÄKTSPOTENTIAL ===
|
|||
|
|
document.getElementById('fc-revenue').innerHTML = `
|
|||
|
|
<div class="rec info"><strong>Bekräftat:</strong> Trygg Bil 222 916 kr/mån (t.o.m. mars 2027)</div>
|
|||
|
|
<div class="rec info"><strong>Pipeline:</strong> quiXzoom Fas 1 (aug 2026). Mål: 3–5 SME-kunder à 30–80k/mån</div>
|
|||
|
|
<div class="rec warn"><strong>Break-even:</strong> Behöver ~${fmt(burnPerMan)} kr/mån. Trygg Bil täcker ${Math.round(TRYGGBIL_MAN/burnPerMan*100)}% av burn.</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// === KOSTNADSNEDBRYTNING ===
|
|||
|
|
const costsRaw = tb.filter(a=>/^[4-8]/.test(a.account_number)&&Math.abs(parseFloat(a.balance||0))>1000)
|
|||
|
|
.map(a=>({name:acname(a),num:a.account_number,ytd:Math.abs(parseFloat(a.balance||0)),snitt:Math.abs(parseFloat(a.balance||0))/6}))
|
|||
|
|
.sort((a,b)=>b.snitt-a.snitt);
|
|||
|
|
const maxSnitt = costsRaw[0]?.snitt || 1;
|
|||
|
|
document.getElementById('fc-total-burn').textContent = fmt(burnPerMan) + '/mån genomsnitt';
|
|||
|
|
document.getElementById('fc-costbars').innerHTML = costsRaw.slice(0,12).map(c=>{
|
|||
|
|
const pct = Math.min(100, c.snitt/maxSnitt*100);
|
|||
|
|
const cls = pct>60?'high':pct>30?'med':'low';
|
|||
|
|
return `<div class="cbar">
|
|||
|
|
<div class="cbar-lbl" title="${esc(c.name)}">${esc(c.num)} ${esc(c.name)}</div>
|
|||
|
|
<div class="cbar-track"><div class="cbar-fill ${cls}" style="width:${pct}%"></div></div>
|
|||
|
|
<div class="cbar-amt">${fmt(c.snitt)}/mån</div>
|
|||
|
|
</div>`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
// KVITTON
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|||
|
|
let _rcptEntryId = null;
|
|||
|
|
|
|||
|
|
async function openReceiptModal(entryId, entryDesc, entryNr) {
|
|||
|
|
_rcptEntryId = entryId;
|
|||
|
|
document.getElementById('rcpt-title').textContent = `📎 Kvitton — #${entryNr}`;
|
|||
|
|
document.getElementById('rcpt-entry-info').textContent = entryDesc || '';
|
|||
|
|
document.getElementById('rcpt-fb').textContent = '';
|
|||
|
|
document.getElementById('rcpt-upload-list').innerHTML = '';
|
|||
|
|
// Hämta befintliga kvitton
|
|||
|
|
const existEl = document.getElementById('rcpt-existing');
|
|||
|
|
existEl.innerHTML = '<div style="color:var(--muted);font-size:.8rem">Laddar…</div>';
|
|||
|
|
openModal('modal-receipt');
|
|||
|
|
try {
|
|||
|
|
const r = await api(`/api/ledger/journal/${entryId}/receipts`);
|
|||
|
|
renderExistingReceipts(r.receipts || []);
|
|||
|
|
} catch(e) {
|
|||
|
|
existEl.innerHTML = '';
|
|||
|
|
}
|
|||
|
|
// Drag & drop
|
|||
|
|
const dz = document.getElementById('rcpt-dropzone');
|
|||
|
|
dz.ondragover = e => { e.preventDefault(); dz.classList.add('drag'); };
|
|||
|
|
dz.ondragleave = () => dz.classList.remove('drag');
|
|||
|
|
dz.ondrop = e => { e.preventDefault(); dz.classList.remove('drag'); handleReceiptFiles(e.dataTransfer.files); };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderExistingReceipts(receipts) {
|
|||
|
|
const el = document.getElementById('rcpt-existing');
|
|||
|
|
if (!receipts.length) { el.innerHTML = '<div style="color:var(--muted);font-size:.8rem;margin-bottom:10px">Inga kvitton uppladdade ännu.</div>'; return; }
|
|||
|
|
const icons = { 'image/jpeg':'🖼️','image/png':'🖼️','application/pdf':'📄','image/heic':'🖼️' };
|
|||
|
|
el.innerHTML = `<div style="font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">${receipts.length} kvitto${receipts.length>1?'n':''} uppladdade</div>` +
|
|||
|
|
receipts.map(r => {
|
|||
|
|
const icon = icons[r.contentType] || '📎';
|
|||
|
|
const kb = r.fileSize ? ` · ${Math.round(r.fileSize/1024)} KB` : '';
|
|||
|
|
const by = r.uploaded_by && r.uploaded_by !== 'unknown' ? ` · ${r.uploaded_by}` : '';
|
|||
|
|
const dt = r.uploaded_at ? ` · ${r.uploaded_at.slice(0,10)}` : '';
|
|||
|
|
return `<div class="receipt-item">
|
|||
|
|
<span class="receipt-item-icon">${icon}</span>
|
|||
|
|
<span class="receipt-item-name" title="${esc(r.filename)}">${esc(r.filename)}</span>
|
|||
|
|
<span class="receipt-item-size">${kb}${by}${dt}</span>
|
|||
|
|
<a href="${esc(r.url)}" target="_blank" class="receipt-item-link">Öppna →</a>
|
|||
|
|
</div>`;
|
|||
|
|
}).join('') + '<div style="height:1px;background:var(--border);margin:10px 0"></div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleReceiptFiles(files) {
|
|||
|
|
if (!_rcptEntryId) return;
|
|||
|
|
const listEl = document.getElementById('rcpt-upload-list');
|
|||
|
|
const fbEl = document.getElementById('rcpt-fb');
|
|||
|
|
fbEl.textContent = '';
|
|||
|
|
for (const file of files) {
|
|||
|
|
if (file.size > 10 * 1024 * 1024) {
|
|||
|
|
fbEl.innerHTML = `<span style="color:var(--red)">⚠️ ${esc(file.name)} är för stor (max 10 MB)</span>`;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const itemId = 'up_' + Date.now() + '_' + Math.random().toString(36).slice(2);
|
|||
|
|
listEl.innerHTML += `<div id="${itemId}" class="receipt-item">
|
|||
|
|
<span class="receipt-item-icon">⏳</span>
|
|||
|
|
<span class="receipt-item-name">${esc(file.name)}</span>
|
|||
|
|
<span class="receipt-item-size">${Math.round(file.size/1024)} KB</span>
|
|||
|
|
<span style="color:var(--muted);font-size:.75rem" id="${itemId}-st">Hämtar URL…</span>
|
|||
|
|
</div><div class="upload-progress" id="${itemId}-bar"><div class="upload-progress-fill" style="width:5%"></div></div>`;
|
|||
|
|
try {
|
|||
|
|
// POST fil direkt till API (raw bytes)
|
|||
|
|
document.querySelector(`#${itemId}-st`).textContent = 'Laddar upp…';
|
|||
|
|
document.querySelector(`#${itemId}-bar .upload-progress-fill`).style.width = '20%';
|
|||
|
|
const ctrl2 = new AbortController();
|
|||
|
|
const uploadRes = await fetch(`/api/ledger/journal/${_rcptEntryId}/upload`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': file.type || 'application/octet-stream',
|
|||
|
|
'X-Filename': file.name,
|
|||
|
|
'x-internal-source': 'ouroboros-system',
|
|||
|
|
'x-tenant-id': TENANT,
|
|||
|
|
},
|
|||
|
|
body: file,
|
|||
|
|
signal: ctrl2.signal,
|
|||
|
|
});
|
|||
|
|
document.querySelector(`#${itemId}-bar .upload-progress-fill`).style.width = '90%';
|
|||
|
|
if (!uploadRes.ok) throw new Error('Upload misslyckades: ' + uploadRes.status);
|
|||
|
|
const confRes = await uploadRes.json();
|
|||
|
|
if (!confRes.ok) throw new Error(confRes.error || 'Okänt fel');
|
|||
|
|
document.querySelector(`#${itemId}-bar .upload-progress-fill`).style.width = '100%';
|
|||
|
|
document.querySelector(`#${itemId}`).querySelector('.receipt-item-icon').textContent = '✅';
|
|||
|
|
document.querySelector(`#${itemId}-st`).innerHTML = `<a href="${esc(confRes.url||'')}" target="_blank" class="receipt-item-link">Öppna →</a>`;
|
|||
|
|
renderExistingReceipts(confRes.receipts);
|
|||
|
|
} catch(err) {
|
|||
|
|
document.querySelector(`#${itemId}`)?.querySelector('.receipt-item-icon') && (document.querySelector(`#${itemId}`).querySelector('.receipt-item-icon').textContent = '❌');
|
|||
|
|
document.querySelector(`#${itemId}-st`) && (document.querySelector(`#${itemId}-st`).textContent = err.message);
|
|||
|
|
fbEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(err.message)}</span>`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function init(){
|
|||
|
|
S.token=TOKEN;S.period=curPeriod();S.periodMode='month';
|
|||
|
|
document.getElementById('e-date').value=today();
|
|||
|
|
buildPeriodSelects();
|
|||
|
|
loadAll();
|
|||
|
|
setInterval(loadAll,60000);
|
|||
|
|
}
|
|||
|
|
document.addEventListener('DOMContentLoaded',init);
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// SKATT & MOMS — SKV-skulder + kalender
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
function loadVATExtras() {
|
|||
|
|
const tb = S.tb;
|
|||
|
|
if (!tb || !tb.length) return;
|
|||
|
|
|
|||
|
|
const getbal = nr => { const a = tb.find(x => x.account_number === nr); return a ? parseFloat(a.balance||0) : 0; };
|
|||
|
|
const fmt2 = n => Math.round(Math.abs(n)).toLocaleString('sv-SE') + ' kr';
|
|||
|
|
|
|||
|
|
// SKV-skulder
|
|||
|
|
const skulder = [
|
|||
|
|
{ nr:'2650', namn:'Momsdeklaration (Q1+Q2)', inst:'Betala till Skatteverket', sev:'critical' },
|
|||
|
|
{ nr:'2710', namn:'Personalskatt', inst:'Betala senast 12:e varje månad', sev:'critical' },
|
|||
|
|
{ nr:'2731', namn:'Arbetsgivaravgift (AGA)', inst:'Betalas med personalskatt', sev:'critical' },
|
|||
|
|
{ nr:'2611', namn:'Utgående moms ej omförd', inst:'Omför till 2650 vid kvartalets slut', sev:'high' },
|
|||
|
|
{ nr:'2615', namn:'Moms omvänd skattskyldighet', inst:'Matchar konto 2645 — kvittar ut', sev:'info' },
|
|||
|
|
];
|
|||
|
|
let totalSkuld = 0;
|
|||
|
|
let bodyHtml = '<table style="width:100%;border-collapse:collapse">';
|
|||
|
|
bodyHtml += '<thead><tr style="border-bottom:1.5px solid #F2F2F7"><th style="padding:8px 14px;text-align:left;font-size:.7rem;color:#8E8E93;text-transform:uppercase">Konto</th><th style="padding:8px 14px;text-align:left;font-size:.7rem;color:#8E8E93;text-transform:uppercase">Skuld</th><th style="padding:8px 14px;text-align:right;font-size:.7rem;color:#8E8E93;text-transform:uppercase">Belopp</th></tr></thead><tbody>';
|
|||
|
|
for (const s of skulder) {
|
|||
|
|
const bal = getbal(s.nr);
|
|||
|
|
const skuld = Math.max(0, -bal);
|
|||
|
|
if (skuld < 1 && s.sev !== 'info') continue;
|
|||
|
|
const col = s.sev==='critical'?'#FF3B30':s.sev==='high'?'#FF9500':'#8E8E93';
|
|||
|
|
const sym = s.sev==='critical'?'⛔':s.sev==='high'?'⚠️':'ℹ️';
|
|||
|
|
const amtStr = skuld > 0 ? `<strong style="color:${col}">${fmt2(skuld)}</strong>` : '<span style="color:#34C759">✅ 0</span>';
|
|||
|
|
bodyHtml += `<tr style="border-bottom:1px solid #F2F2F7">
|
|||
|
|
<td style="padding:10px 14px;font-size:.78rem;color:#666">${sym} ${s.nr}</td>
|
|||
|
|
<td style="padding:10px 14px"><div style="font-size:.82rem;font-weight:600;color:#1c1c1e">${s.namn}</div><div style="font-size:.72rem;color:#8E8E93;margin-top:2px">${s.inst}</div></td>
|
|||
|
|
<td style="padding:10px 14px;text-align:right">${amtStr}</td>
|
|||
|
|
</tr>`;
|
|||
|
|
if (s.sev !== 'info') totalSkuld += skuld;
|
|||
|
|
}
|
|||
|
|
// Totalt
|
|||
|
|
bodyHtml += `<tr style="border-top:2px solid #1c1c1e;background:#FAFAFA">
|
|||
|
|
<td colspan="2" style="padding:10px 14px;font-size:.83rem;font-weight:700">Totalt att betala till SKV nu</td>
|
|||
|
|
<td style="padding:10px 14px;text-align:right;font-size:.9rem;font-weight:800;color:${totalSkuld>0?'#FF3B30':'#34C759'}">${totalSkuld>0?fmt2(totalSkuld):'✅ Betalt'}</td>
|
|||
|
|
</tr>`;
|
|||
|
|
bodyHtml += '</tbody></table>';
|
|||
|
|
const skvBody = document.getElementById('skv-skulder-body');
|
|||
|
|
if (skvBody) skvBody.innerHTML = bodyHtml;
|
|||
|
|
const badge = document.getElementById('skv-total-badge');
|
|||
|
|
if (badge) badge.textContent = totalSkuld > 0 ? fmt2(totalSkuld) : '✅ OK';
|
|||
|
|
|
|||
|
|
// Skattekalender
|
|||
|
|
const now = new Date();
|
|||
|
|
const m = now.getMonth()+1, y = now.getFullYear();
|
|||
|
|
const cal = [
|
|||
|
|
{ datum:`${y}-${String(m).padStart(2,'0')}-12`, namn:'Personalskatt + AGA', konto:'2710 / 2731', sev: getbal('2710') < -100 || getbal('2731') < -100 ? 'critical' : 'ok' },
|
|||
|
|
{ datum:`${y}-05-26`, namn:'Momsdeklaration Q1', konto:'2650', sev: getbal('2650') < -100 ? 'critical' : 'ok' },
|
|||
|
|
{ datum:`${y}-08-26`, namn:'Momsdeklaration Q2', konto:'2650', sev:'upcoming' },
|
|||
|
|
{ datum:`${y}-11-26`, namn:'Momsdeklaration Q3', konto:'2650', sev:'upcoming' },
|
|||
|
|
].sort((a,b)=>a.datum.localeCompare(b.datum));
|
|||
|
|
let calHtml = '';
|
|||
|
|
for (const e of cal) {
|
|||
|
|
const daysLeft = Math.ceil((new Date(e.datum)-now)/(1000*60*60*24));
|
|||
|
|
const col = e.sev==='critical'?'#FF3B30':e.sev==='ok'?'#34C759':'#FF9500';
|
|||
|
|
const lbl = daysLeft < 0 ? '🔴 FÖRFALLEN' : daysLeft===0 ? '🔴 IDAG' : daysLeft<=7 ? `⚠️ om ${daysLeft} dagar` : `${daysLeft} dagar kvar`;
|
|||
|
|
calHtml += `<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid #F2F2F7">
|
|||
|
|
<div><div style="font-size:.83rem;font-weight:600">${e.namn}</div><div style="font-size:.73rem;color:#8E8E93">Konto ${e.konto} — förfaller ${e.datum}</div></div>
|
|||
|
|
<span style="font-size:.76rem;font-weight:700;color:${col};white-space:nowrap">${lbl}</span>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
const calBody = document.getElementById('skv-calendar-body');
|
|||
|
|
if (calBody) calBody.innerHTML = calHtml || '<div style="color:#34C759">✅ Inga kommande förfallodatum</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// LÖNER — loadPayroll()
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
async function loadPayroll() {
|
|||
|
|
const fmtN = n => Math.round(Math.abs(n)).toLocaleString('sv-SE') + ' kr';
|
|||
|
|
const fmtC = (n, pos='#34C759', neg='#FF3B30') => `<span style="color:${n>=0?pos:neg};font-weight:700">${fmtN(n)}</span>`;
|
|||
|
|
|
|||
|
|
// Hämta direkt från API med posted-filter — aldrig trial balance (kan ha stale data)
|
|||
|
|
let entries = [];
|
|||
|
|
try {
|
|||
|
|
// Hämta alla lönerelaterade verifikat (status=posted)
|
|||
|
|
const r = await api('/api/ledger/journal?limit=500');
|
|||
|
|
entries = (r.entries || []).filter(e => e.status === 'posted');
|
|||
|
|
} catch(e) {
|
|||
|
|
document.getElementById('pr-persons-body').innerHTML = '<div style="padding:14px;color:#FF3B30">Kunde inte ladda lönedata: ' + e.message + '</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Aggregera per konto — BARA posted
|
|||
|
|
const loneKonton = {'7010':0,'7210':0,'7510':0};
|
|||
|
|
const perioder = {};
|
|||
|
|
const personPoster = {'7010':[],'7210':[]};
|
|||
|
|
|
|||
|
|
for (const e of entries) {
|
|||
|
|
if (!e.lines) continue;
|
|||
|
|
const hasLon = e.lines.some(l => ['7010','7210','7510'].includes(l.account_number));
|
|||
|
|
if (!hasLon) continue;
|
|||
|
|
for (const l of e.lines) {
|
|||
|
|
const acc = l.account_number;
|
|||
|
|
if (loneKonton[acc] !== undefined) {
|
|||
|
|
loneKonton[acc] += parseFloat(l.debit||0) - parseFloat(l.credit||0);
|
|||
|
|
}
|
|||
|
|
if (personPoster[acc]) {
|
|||
|
|
personPoster[acc].push({ entry: e.entry_number, date: e.entry_date, desc: e.description, debit: parseFloat(l.debit||0), credit: parseFloat(l.credit||0) });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Period-aggregering
|
|||
|
|
const p = e.period;
|
|||
|
|
if (!perioder[p]) perioder[p] = {lon:0, aga:0, entries:[]};
|
|||
|
|
for (const l of e.lines) {
|
|||
|
|
if (['7010','7210'].includes(l.account_number)) perioder[p].lon += parseFloat(l.debit||0) - parseFloat(l.credit||0);
|
|||
|
|
if (l.account_number==='7510') perioder[p].aga += parseFloat(l.debit||0);
|
|||
|
|
}
|
|||
|
|
perioder[p].entries.push(e.entry_number);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// KPI — använd trial balance för skatteskulder (de är rätt)
|
|||
|
|
const tb = S.tb;
|
|||
|
|
const getbal = nr => { const a = tb.find(x => x.account_number === nr); return a ? parseFloat(a.balance||0) : 0; };
|
|||
|
|
const bruttoLon = Math.max(0, loneKonton['7010'] + loneKonton['7210']);
|
|||
|
|
const aga = Math.max(0, loneKonton['7510']);
|
|||
|
|
const persSkSkuld = Math.max(0, -getbal('2710'));
|
|||
|
|
const agaSkuld = Math.max(0, -getbal('2731'));
|
|||
|
|
const skuldSKV = persSkSkuld + agaSkuld;
|
|||
|
|
|
|||
|
|
document.getElementById('pr-lon-v').textContent = fmtN(bruttoLon);
|
|||
|
|
document.getElementById('pr-aga-v').textContent = fmtN(aga);
|
|||
|
|
document.getElementById('pr-skuld-v').textContent = fmtN(skuldSKV);
|
|||
|
|
|
|||
|
|
// Räkna aktiva — baserat på konton med saldo > 0
|
|||
|
|
const antalPersoner = (loneKonton['7210'] > 0 ? 1 : 0) + (loneKonton['7010'] > 0 ? 1 : 0);
|
|||
|
|
document.getElementById('pr-antal-v').textContent = antalPersoner > 0 ? antalPersoner + ' pers.' : '—';
|
|||
|
|
|
|||
|
|
// ── LÖNER PER PERSON — klickbara rader ──────────────────────────────
|
|||
|
|
const persons = [
|
|||
|
|
{ namn:'Erik Svensson', konto:'7210', titel:'VD', not:'Konto 7210 — förskott utan lönespecifikation', lon: Math.max(0, loneKonton['7210']) },
|
|||
|
|
{ namn:'Anställda', konto:'7010', titel:'Kollektivanställda', not:'Konto 7010 — Johan Berglund m.fl.', lon: Math.max(0, loneKonton['7010']) },
|
|||
|
|
].filter(p => p.lon > 0);
|
|||
|
|
|
|||
|
|
let persHtml = '<table style="width:100%;border-collapse:collapse">';
|
|||
|
|
persHtml += '<thead><tr style="border-bottom:1.5px solid #F2F2F7">'
|
|||
|
|
+ '<th style="padding:8px 14px;text-align:left;font-size:.7rem;color:#8E8E93;text-transform:uppercase">Person</th>'
|
|||
|
|
+ '<th style="padding:8px 14px;text-align:right;font-size:.7rem;color:#8E8E93;text-transform:uppercase">Bruttolön YTD</th>'
|
|||
|
|
+ '</tr></thead><tbody>';
|
|||
|
|
for (const p of persons) {
|
|||
|
|
const poster = personPoster[p.konto];
|
|||
|
|
window._payrollPersons = window._payrollPersons || {};
|
|||
|
|
window._payrollPersons[p.konto] = { namn: p.namn, not: p.not, poster };
|
|||
|
|
persHtml += `<tr style="border-bottom:1px solid #F2F2F7;cursor:pointer"
|
|||
|
|
onclick="openPayrollPerson('${p.konto}')"
|
|||
|
|
onmouseover="this.style.background='#F9F9F9'" onmouseout="this.style.background=''">
|
|||
|
|
<td style="padding:10px 14px">
|
|||
|
|
<div style="font-size:.84rem;font-weight:600">${p.namn}</div>
|
|||
|
|
<div style="font-size:.72rem;color:#8E8E93">${p.titel} — konto ${p.konto}</div>
|
|||
|
|
<div style="font-size:.71rem;color:#007AFF;margin-top:2px">${p.not} — klicka för poster ›</div>
|
|||
|
|
</td>
|
|||
|
|
<td style="padding:10px 14px;text-align:right;font-size:.88rem;font-weight:700">${fmtN(p.lon)}</td>
|
|||
|
|
</tr>`;
|
|||
|
|
}
|
|||
|
|
if (!persons.length) persHtml += '<tr><td colspan="2" style="padding:16px 14px;color:#8E8E93;font-size:.82rem">Inga löneposter bokförda</td></tr>';
|
|||
|
|
persHtml += `<tr style="border-top:2px solid #1c1c1e;background:#FAFAFA">
|
|||
|
|
<td style="padding:9px 14px;font-weight:700;font-size:.83rem">Totalt bruttolöner YTD</td>
|
|||
|
|
<td style="padding:9px 14px;text-align:right;font-size:.9rem;font-weight:800">${fmtN(bruttoLon)}</td>
|
|||
|
|
</tr></tbody></table>`;
|
|||
|
|
document.getElementById('pr-persons-body').innerHTML = persHtml;
|
|||
|
|
|
|||
|
|
// ── LÖNER PER PERIOD — klickbara rader ──────────────────────────────
|
|||
|
|
const sortedPeriods = Object.keys(perioder).sort();
|
|||
|
|
let perHtml = '<table style="width:100%;border-collapse:collapse">';
|
|||
|
|
perHtml += '<thead><tr style="border-bottom:1.5px solid #F2F2F7">'
|
|||
|
|
+ '<th style="padding:8px 14px;text-align:left;font-size:.7rem;color:#8E8E93;text-transform:uppercase">Period</th>'
|
|||
|
|
+ '<th style="padding:8px 14px;text-align:right;font-size:.7rem;color:#8E8E93;text-transform:uppercase">Bruttolön</th>'
|
|||
|
|
+ '<th style="padding:8px 14px;text-align:right;font-size:.7rem;color:#8E8E93;text-transform:uppercase">AGA</th>'
|
|||
|
|
+ '<th style="padding:8px 14px;text-align:right;font-size:.7rem;color:#8E8E93;text-transform:uppercase">Total</th>'
|
|||
|
|
+ '</tr></thead><tbody>';
|
|||
|
|
let grandLon = 0, grandAga = 0;
|
|||
|
|
for (const p of sortedPeriods) {
|
|||
|
|
const d = perioder[p];
|
|||
|
|
const lon = Math.max(0, d.lon), ag = Math.max(0, d.aga);
|
|||
|
|
grandLon += lon; grandAga += ag;
|
|||
|
|
const verNr = d.entries.join(', #');
|
|||
|
|
perHtml += `<tr style="border-bottom:1px solid #F2F2F7;cursor:pointer"
|
|||
|
|
onclick="openInfo('Löner ${p}','<div style=\\'font-size:.82rem\\'><b>Verifikat:</b> #${verNr}</div><div style=\\'margin-top:8px;font-size:.82rem\\'>Bruttolön: <b>${fmtN(lon)}</b><br>AGA: <b>${fmtN(ag)}</b><br>Total: <b>${fmtN(lon+ag)}</b></div>')"
|
|||
|
|
onmouseover="this.style.background='#F9F9F9'" onmouseout="this.style.background=''">
|
|||
|
|
<td style="padding:9px 14px;font-size:.82rem;font-weight:600">${p} <span style="font-size:.7rem;color:#007AFF">›</span></td>
|
|||
|
|
<td style="padding:9px 14px;text-align:right;font-size:.82rem">${fmtN(lon)}</td>
|
|||
|
|
<td style="padding:9px 14px;text-align:right;font-size:.82rem">${fmtN(ag)}</td>
|
|||
|
|
<td style="padding:9px 14px;text-align:right;font-size:.84rem;font-weight:700">${fmtN(lon+ag)}</td>
|
|||
|
|
</tr>`;
|
|||
|
|
}
|
|||
|
|
if (!sortedPeriods.length) {
|
|||
|
|
perHtml += '<tr><td colspan="4" style="padding:16px 14px;color:#8E8E93;font-size:.82rem">Inga löneposter hittade</td></tr>';
|
|||
|
|
} else {
|
|||
|
|
perHtml += `<tr style="border-top:2px solid #1c1c1e;background:#FAFAFA">
|
|||
|
|
<td style="padding:9px 14px;font-weight:700;font-size:.83rem">Totalt YTD</td>
|
|||
|
|
<td style="padding:9px 14px;text-align:right;font-size:.83rem;font-weight:700">${fmtN(grandLon)}</td>
|
|||
|
|
<td style="padding:9px 14px;text-align:right;font-size:.83rem;font-weight:700">${fmtN(grandAga)}</td>
|
|||
|
|
<td style="padding:9px 14px;text-align:right;font-size:.9rem;font-weight:800">${fmtN(grandLon+grandAga)}</td>
|
|||
|
|
</tr>`;
|
|||
|
|
}
|
|||
|
|
perHtml += '</tbody></table>';
|
|||
|
|
document.getElementById('pr-periods-body').innerHTML = perHtml;
|
|||
|
|
|
|||
|
|
// ── AGA & PERSONALSKATT ──────────────────────────────────────────────
|
|||
|
|
const skvData = [
|
|||
|
|
{ namn:'Bokförd AGA YTD (konto 7510)', val: aga, not:'31,42% × bruttolön', col:'#FF9500' },
|
|||
|
|
{ namn:'Personalskatt att betala (konto 2710)', val: persSkSkuld, not:'Betalas till SKV senast 12:e', col: persSkSkuld>0?'#FF3B30':'#34C759' },
|
|||
|
|
{ namn:'Arbetsgivaravgift att betala (konto 2731)', val: agaSkuld, not:'Betalas med personalskatt', col: agaSkuld>0?'#FF3B30':'#34C759' },
|
|||
|
|
];
|
|||
|
|
let skvHtml = '';
|
|||
|
|
for (const r of skvData) {
|
|||
|
|
skvHtml += `<div style="display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-bottom:1px solid #F2F2F7;cursor:pointer"
|
|||
|
|
onclick="showSkuldModal()"
|
|||
|
|
onmouseover="this.style.background='#F9F9F9'" onmouseout="this.style.background=''">
|
|||
|
|
<div><div style="font-size:.83rem;font-weight:600">${r.namn}</div>
|
|||
|
|
<div style="font-size:.72rem;color:#8E8E93">${r.not} <span style="color:#007AFF">— klicka för detaljer ›</span></div></div>
|
|||
|
|
<span style="font-size:.9rem;font-weight:800;color:${r.col};white-space:nowrap;margin-left:12px">${fmtN(r.val)}</span>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
const totalToPay = persSkSkuld + agaSkuld;
|
|||
|
|
skvHtml += `<div style="display:flex;justify-content:space-between;align-items:baseline;padding:10px 0;border-top:2px solid #1c1c1e;margin-top:2px">
|
|||
|
|
<span style="font-size:.85rem;font-weight:700">Totalt att betala till SKV</span>
|
|||
|
|
<span style="font-size:.95rem;font-weight:800;color:${totalToPay>0?'#FF3B30':'#34C759'}">${totalToPay>0?fmtN(totalToPay):'✅ Betalt'}</span>
|
|||
|
|
</div>`;
|
|||
|
|
const badge = document.getElementById('pr-skv-badge');
|
|||
|
|
if (badge) { badge.textContent = totalToPay>0 ? fmtN(totalToPay) + ' att betala' : '✅'; badge.style.background = totalToPay>0?'#FF3B30':'#34C759'; badge.style.color='#fff'; }
|
|||
|
|
document.getElementById('pr-skv-body').innerHTML = skvHtml;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openPayrollPerson(konto) {
|
|||
|
|
const data = window._payrollPersons && window._payrollPersons[konto];
|
|||
|
|
if (!data) return;
|
|||
|
|
const fmtN2 = n => Math.round(Math.abs(n)).toLocaleString('sv-SE') + ' kr';
|
|||
|
|
const rows = data.poster.map(e =>
|
|||
|
|
'<div style="padding:7px 0;border-bottom:1px solid #F2F2F7;display:flex;justify-content:space-between">' +
|
|||
|
|
'<div><div style="font-size:.82rem;font-weight:600">Ver. #' + e.entry + '</div>' +
|
|||
|
|
'<div style="font-size:.72rem;color:#8E8E93">' + (e.date||'').slice(0,10) + ' — ' + (e.desc||'').slice(0,50) + '</div></div>' +
|
|||
|
|
'<span style="font-size:.85rem;font-weight:700;color:#1c1c1e">' + fmtN2(e.debit - e.credit) + '</span></div>'
|
|||
|
|
).join('');
|
|||
|
|
const html = '<div style="font-size:.78rem;color:#8E8E93;margin-bottom:10px">' + data.not + '</div>' + rows;
|
|||
|
|
openInfo(data.namn + ' — löneposter', html);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// ONBOARDING — rollbaserad, kontextmedveten
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
const OB_PROFILES = {
|
|||
|
|
ceo: {
|
|||
|
|
name: 'Erik Svensson', greeting: 'Välkommen tillbaka, Erik', color: '#007AFF',
|
|||
|
|
steps: [
|
|||
|
|
{ icon: '📊', title: 'Din kontrollpanel', text: 'Kassa, intäkter och resultat i realtid. Klicka på valfri siffra för detaljer — konton, skulder, valutauppdelning.', target: '.kpi-row' },
|
|||
|
|
{ icon: '⛔', title: 'Tasks — det som kräver din handling', text: 'Tasks-fliken visar allt som väntar: obetalda skatter, öppna perioder, fordringar. Rött = kritiskt. Börja alltid här.', target: '#tasks-tab-btn' },
|
|||
|
|
{ icon: '🏦', title: 'Skatt & Moms — en flik, allt samlat', text: 'Moms, personalskatt och arbetsgivaravgift på ett ställe. Förfallodatum och exakta belopp. Inga överraskningar.', target: '[onclick="switchTab(\'vat\')"]' },
|
|||
|
|
{ icon: '👥', title: 'Löner — per person och period', text: 'Se bruttolöner, AGA och vad som är kvar att betala till SKV. Klicka "Att betala SKV ›" för full uppdelning.', target: '[onclick="switchTab(\'payroll\')"]' },
|
|||
|
|
{ icon: '📷', title: 'Utlägg — foto räcker', text: 'Öppna ett verifikat i Transaktioner → 📷 Ta foto. Kvittot kopplas direkt. Fungerar från mobilen.', target: '[onclick="switchTab(\'transactions\')"]' },
|
|||
|
|
{ icon: '📤', title: 'SIE4 till revisorn', text: 'Exportera bokföringen med ett klick. Skickas automatiskt till Andreas Vretblom (KPMG) vid periodsslut.', target: '[onclick="doSIE()"]' }
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
revisor: {
|
|||
|
|
name: 'Andreas Vretblom', greeting: 'Välkommen, Andreas', color: '#5856D6',
|
|||
|
|
steps: [
|
|||
|
|
{ icon: '📋', title: 'Bokföringsöversikt LandveX AB', text: 'Trial balance, P&L och balansräkning. FY 2025/2026 (maj–april). Välj period i period-baren ovan.', target: '.period-bar' },
|
|||
|
|
{ icon: '⚖️', title: 'Balansen håller', text: 'Dubbel bokföring verifierad: 9 718 214 kr debet = kredit. 5 perioder stängda (jan–maj 2026).', target: '.kpi-row' },
|
|||
|
|
{ icon: '📤', title: 'SIE4-export', text: 'Klicka SIE4 för att ladda ned hela bokföringen. Direkt import i era revisionssystem.', target: '[onclick="doSIE()"]' },
|
|||
|
|
{ icon: '🏦', title: 'Skatteskulder', text: 'Momsdeklaration 332 826 kr obetald. Personalskatt 26 212 kr. Detaljer i Skatt & Moms-fliken.', target: '[onclick="switchTab(\'vat\')"]' },
|
|||
|
|
{ icon: '📋', title: 'Verifikat & underlag', text: '1 092 bokförda verifikat. Klicka på en rad för kvitton och underlag.', target: '[onclick="switchTab(\'transactions\')"]' }
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
cto: {
|
|||
|
|
name: 'Johan Berglund', greeting: 'Välkommen, Johan', color: '#FF9500',
|
|||
|
|
steps: [
|
|||
|
|
{ icon: '⚡', title: 'Systemstatus & API', text: 'Bokförings-API:t körs på aamos-ledger (port 3250). Realtidsdata. Kontrollera att alla endpoints svarar i Tasks-fliken.', target: '#tasks-tab-btn' },
|
|||
|
|
{ icon: '📊', title: 'Kassaflöde & burn', text: 'Kassa och burn rate i realtid. Klicka KPI-korten för underliggande kontodata och verifikat.', target: '.kpi-row' },
|
|||
|
|
{ icon: '🔁', title: 'Löpande bokföring', text: 'Transaktioner och verifikat. Kontrollera att API-integrationer (Stripe, Swan) bokför korrekt.', target: '[onclick="switchTab(\'transactions\')"]' },
|
|||
|
|
{ icon: '💰', title: 'Löner & kostnader', text: 'Löneutbetalningar, AGA och prelskatt. Dina löneposter bokförs på konto 7010.', target: '[onclick="switchTab(\'payroll\')"]' },
|
|||
|
|
{ icon: '📤', title: 'SIE4-export', text: 'Exportera bokföringen för revisor (KPMG). Används vid periodsslut och på begäran.', target: '[onclick="doSIE()"]' }
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
bookkeeper: {
|
|||
|
|
name: 'Ekonomiassistent', greeting: 'Välkommen', color: '#34C759',
|
|||
|
|
steps: [
|
|||
|
|
{ icon: '➕', title: 'Bokför en transaktion', text: 'Klicka "+ Ny verifikation". Fyll i datum, beskrivning och debet/kredit-rader. Systemet kontrollerar att det balanserar.', target: '.btn-top' },
|
|||
|
|
{ icon: '📋', title: 'Transaktionslistan', text: 'Alla verifikat med sök på leverantör, belopp eller konto. Klicka för detaljer och kvitton.', target: '[onclick="switchTab(\'transactions\')"]' },
|
|||
|
|
{ icon: '📷', title: 'Ladda upp kvitto', text: 'Öppna ett verifikat → dra fil hit eller 📷 Ta foto. JPG, PNG, PDF upp till 12 MB.', target: null },
|
|||
|
|
{ icon: '✅', title: 'Tasks — din att-göra-lista', text: 'Visa vad som saknar underlag eller behöver åtgärd. Arbeta igenom uppifrån.', target: '#tasks-tab-btn' },
|
|||
|
|
{ icon: '📅', title: 'Periodstängning', text: 'Period-fliken → Submit for review när månaden är klar. Controllern godkänner.', target: '[onclick="switchTab(\'period\')"]' }
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
default: {
|
|||
|
|
name: '', greeting: 'Välkommen till LandveX Ekonomi', color: '#007AFF',
|
|||
|
|
steps: [
|
|||
|
|
{ icon: '📊', title: 'Kassa & resultat', text: 'Klicka på valfri siffra för detaljer.', target: '.kpi-row' },
|
|||
|
|
{ icon: '✅', title: 'Tasks', text: 'Tasks-fliken visar vad som kräver åtgärd.', target: '#tasks-tab-btn' },
|
|||
|
|
{ icon: '➕', title: 'Bokför', text: '"+ Ny verifikation" för att bokföra.', target: '.btn-top' }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function detectRole() {
|
|||
|
|
const urlRole = new URLSearchParams(location.search).get('role');
|
|||
|
|
if (urlRole && OB_PROFILES[urlRole]) return urlRole;
|
|||
|
|
const stored = localStorage.getItem('ob-role');
|
|||
|
|
if (stored && OB_PROFILES[stored]) return stored;
|
|||
|
|
return 'ceo';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let obStep = 0;
|
|||
|
|
let obActive = false;
|
|||
|
|
let obProfile = null;
|
|||
|
|
|
|||
|
|
function obShowRolePicker() {
|
|||
|
|
const roles = [
|
|||
|
|
{ key: 'ceo', icon: '👔', label: 'Erik Svensson', sub: 'CEO / Ägare' },
|
|||
|
|
{ key: 'cto', icon: '⚡', label: 'Johan Berglund', sub: 'CTO' },
|
|||
|
|
{ key: 'revisor', icon: '🏛', label: 'Andreas Vretblom', sub: 'Revisor (KPMG)' },
|
|||
|
|
{ key: 'bookkeeper', icon: '📋', label: 'Ekonomiassistent', sub: 'Bokförare' },
|
|||
|
|
];
|
|||
|
|
const html = '<div style="font-size:.78rem;color:#8E8E93;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px">Välj din roll för en anpassad guide</div>' +
|
|||
|
|
roles.map(r =>
|
|||
|
|
'<div onclick="closeModal(\'modal-info\');localStorage.setItem(\'ob-role\',\'' + r.key + '\');obStart(\'' + r.key + '\')" ' +
|
|||
|
|
'style="display:flex;align-items:center;gap:12px;padding:13px;border-radius:10px;cursor:pointer;border:1.5px solid #F2F2F7;margin-bottom:8px" ' +
|
|||
|
|
'onmouseover="this.style.background=\'#F2F2F7\'" onmouseout="this.style.background=\'\'">' +
|
|||
|
|
'<span style="font-size:1.5rem">' + r.icon + '</span>' +
|
|||
|
|
'<div><div style="font-size:.87rem;font-weight:600">' + r.label + '</div>' +
|
|||
|
|
'<div style="font-size:.75rem;color:#8E8E93">' + r.sub + '</div></div></div>'
|
|||
|
|
).join('') +
|
|||
|
|
'<div style="margin-top:6px;font-size:.74rem;color:#8E8E93;text-align:center">Välj en gång — sparas automatiskt. Byt via ? uppe till höger.</div>';
|
|||
|
|
openInfo('Välkommen — vem är du?', html);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function obStart(roleOverride) {
|
|||
|
|
if (document.getElementById('ob-backdrop')) return;
|
|||
|
|
const role = roleOverride || detectRole();
|
|||
|
|
obProfile = OB_PROFILES[role] || OB_PROFILES.default;
|
|||
|
|
localStorage.setItem('ob-seen', '1');
|
|||
|
|
localStorage.setItem('ob-role', role);
|
|||
|
|
obStep = 0; obActive = true;
|
|||
|
|
const bd = document.createElement('div');
|
|||
|
|
bd.id = 'ob-backdrop';
|
|||
|
|
bd.innerHTML =
|
|||
|
|
'<div id="ob-highlight" style="display:none"></div>' +
|
|||
|
|
'<div id="ob-box" style="--ob-accent:' + obProfile.color + '">' +
|
|||
|
|
'<button id="ob-close" onclick="obEnd()" title="Stäng">✕</button>' +
|
|||
|
|
'<div id="ob-step-num"></div><div id="ob-icon"></div>' +
|
|||
|
|
'<div id="ob-title"></div><div id="ob-text"></div>' +
|
|||
|
|
'<div id="ob-actions"><div id="ob-dots"></div>' +
|
|||
|
|
'<div style="display:flex;gap:8px;align-items:center">' +
|
|||
|
|
'<button id="ob-skip" onclick="obEnd()">Hoppa över</button>' +
|
|||
|
|
'<button id="ob-next" onclick="obNext()">Nästa →</button>' +
|
|||
|
|
'</div></div></div>';
|
|||
|
|
document.body.appendChild(bd);
|
|||
|
|
bd.addEventListener('click', e => { if (e.target === bd) obEnd(); });
|
|||
|
|
obRender();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function obRender() {
|
|||
|
|
if (!obProfile) return;
|
|||
|
|
const steps = obProfile.steps;
|
|||
|
|
const s = steps[obStep];
|
|||
|
|
document.getElementById('ob-step-num').textContent =
|
|||
|
|
obStep === 0 && obProfile.greeting ? obProfile.greeting : 'Steg ' + (obStep+1) + ' av ' + steps.length;
|
|||
|
|
document.getElementById('ob-icon').textContent = s.icon;
|
|||
|
|
document.getElementById('ob-title').textContent = s.title;
|
|||
|
|
document.getElementById('ob-text').textContent = s.text;
|
|||
|
|
document.getElementById('ob-next').textContent = obStep === steps.length-1 ? '✓ Klar' : 'Nästa →';
|
|||
|
|
document.getElementById('ob-skip').style.display = obStep === steps.length-1 ? 'none' : '';
|
|||
|
|
document.getElementById('ob-dots').innerHTML = steps.map((_,i) =>
|
|||
|
|
'<span class="ob-dot' + (i===obStep?' active':'') + '" style="' + (i===obStep?'background:'+obProfile.color:'') + '"></span>'
|
|||
|
|
).join('');
|
|||
|
|
const hl = document.getElementById('ob-highlight');
|
|||
|
|
if (s.target) {
|
|||
|
|
const el = document.querySelector(s.target);
|
|||
|
|
if (el) {
|
|||
|
|
const r = el.getBoundingClientRect();
|
|||
|
|
hl.style.cssText = 'display:block;top:' + (r.top+scrollY-8) + 'px;left:' + (r.left-8) + 'px;width:' + (r.width+16) + 'px;height:' + (r.height+16) + 'px;border:2.5px solid ' + obProfile.color + ';border-radius:10px;position:absolute;pointer-events:none;z-index:9998;box-shadow:0 0 0 4000px rgba(0,0,0,.45)';
|
|||
|
|
} else { hl.style.display='none'; }
|
|||
|
|
} else { hl.style.display='none'; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function obNext() {
|
|||
|
|
if (!obProfile || obStep >= obProfile.steps.length-1) { obEnd(); return; }
|
|||
|
|
obStep++; obRender();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function obEnd() {
|
|||
|
|
const bd = document.getElementById('ob-backdrop');
|
|||
|
|
if (bd) bd.remove();
|
|||
|
|
obActive = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|||
|
|
if (!localStorage.getItem('ob-seen')) { setTimeout(obShowRolePicker, 1200); }
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|