Files
boc/finance-index-patch.html
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

1609 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ouroboros Finance</title>
<style>
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #FAF9F6;
--navy: #001E50;
--amber: #C8763A;
--card: #FFFFFF;
--border: #E8E3DC;
--muted: #6B6B70;
--green: #34C759;
--red: #FF3B30;
--navy-10: #001E5010;
--navy-05: #001E5008;
--amber-10:#C8763A18;
--shadow: 0 1px 4px rgba(0,30,80,.07), 0 4px 16px rgba(0,30,80,.06);
--radius: 12px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
html { font-family: var(--font); font-size: 15px; background: var(--bg); color: var(--navy); }
body { min-height: 100vh; }
a { color: var(--amber); text-decoration: none; }
a:hover { text-decoration: underline; }
button { font-family: var(--font); cursor: pointer; }
input, select, textarea { font-family: var(--font); font-size: .93rem; }
/* ── Topbar ── */
#topbar {
position: sticky; top: 0; z-index: 100;
background: var(--navy); color: #fff;
display: flex; align-items: center; gap: 16px;
padding: 0 28px; height: 56px;
box-shadow: 0 2px 8px rgba(0,0,0,.18);
}
.topbar-brand { display: flex; align-items: center; gap: 10px; flex: 1; }
.brand-logo {
width: 30px; height: 30px; border-radius: 50%;
background: var(--amber); display: flex; align-items: center; justify-content: center;
font-size: 16px; font-weight: 800; color: #fff; flex-shrink: 0;
}
.brand-name { font-size: .95rem; font-weight: 700; letter-spacing: .02em; white-space: nowrap; }
.brand-sep { color: rgba(255,255,255,.3); margin: 0 4px; }
#company-name { font-size: .88rem; color: rgba(255,255,255,.7); font-weight: 400; }
.topbar-center { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.topbar-right { display: flex; align-items: center; gap: 10px; flex: 1; justify-content: flex-end; }
.period-label { font-size: .8rem; color: rgba(255,255,255,.6); }
#period-select {
background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.2);
color: #fff; border-radius: 8px; padding: 6px 10px; font-size: .88rem;
cursor: pointer; outline: none;
}
#period-select option { background: var(--navy); }
#period-select:hover { background: rgba(255,255,255,.15); }
.btn-amber {
background: var(--amber); color: #fff; border: none;
border-radius: 8px; padding: 8px 16px; font-size: .88rem; font-weight: 600;
display: flex; align-items: center; gap: 6px; transition: background .15s;
}
.btn-amber:hover { background: #b0612e; }
.btn-icon {
background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.2);
color: #fff; border-radius: 8px; width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center; font-size: 17px;
transition: background .15s;
}
.btn-icon:hover { background: rgba(255,255,255,.2); }
#data-status {
font-size: .75rem; color: rgba(255,255,255,.5);
display: flex; align-items: center; gap: 5px;
}
.status-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--muted); display: inline-block; flex-shrink: 0;
}
.status-dot.live { background: var(--green); box-shadow: 0 0 0 2px rgba(52,199,89,.3); }
.status-dot.demo { background: var(--amber); }
.status-dot.error { background: var(--red); }
/* ── Tab nav ── */
#tab-nav {
display: flex; align-items: center; gap: 4px;
background: rgba(255,255,255,.08); border-radius: 10px; padding: 4px;
}
.tab-btn {
background: transparent; border: none; color: rgba(255,255,255,.65);
padding: 6px 13px; border-radius: 7px; font-size: .83rem; font-weight: 500;
cursor: pointer; transition: background .15s, color .15s; white-space: nowrap;
}
.tab-btn:hover { background: rgba(255,255,255,.12); color: #fff; }
.tab-btn.active { background: var(--amber); color: #fff; font-weight: 600; }
/* ── Main layout ── */
#main { max-width: 1260px; margin: 0 auto; padding: 24px 24px 0; }
/* ── Tab sections ── */
.tab-section { display: none; }
.tab-section.active { display: block; }
/* ── KPI row ── */
#kpi-row {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 20px;
}
.kpi-card {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 20px 22px; box-shadow: var(--shadow); position: relative; overflow: hidden;
transition: box-shadow .2s;
}
.kpi-card:hover { box-shadow: 0 4px 20px rgba(0,30,80,.12); }
.kpi-card::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--border); border-radius: 4px 0 0 4px;
}
.kpi-card.green::before { background: var(--green); }
.kpi-card.amber::before { background: var(--amber); }
.kpi-card.navy::before { background: var(--navy); }
.kpi-card.red::before { background: var(--red); }
.kpi-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.kpi-icon { font-size: 1.2rem; }
.kpi-value { font-size: 1.5rem; font-weight: 800; font-variant-numeric: tabular-nums; margin-bottom: 4px; }
.kpi-value.green { color: var(--green); }
.kpi-value.amber { color: var(--amber); }
.kpi-value.navy { color: var(--navy); }
.kpi-value.red { color: var(--red); }
.kpi-label { font-size: .78rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
.kpi-sub { font-size: .73rem; color: var(--muted); margin-top: 3px; }
.kpi-trend { font-size: .73rem; font-weight: 600; padding: 2px 6px; border-radius: 4px; }
.kpi-trend.flat { color: var(--muted); background: var(--navy-05); }
/* ── Cards ── */
.card {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow); margin-bottom: 16px; overflow: hidden;
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid var(--border);
background: var(--navy-05);
}
.card-body { padding: 16px 18px; }
.card-title { font-size: .93rem; font-weight: 700; color: var(--navy); letter-spacing: .01em; }
.card-badge {
font-size: .73rem; font-weight: 600; padding: 3px 8px; border-radius: 5px;
background: var(--navy-10); color: var(--navy);
}
/* ── Content row ── */
#content-row { display: grid; grid-template-columns: 1fr 360px; gap: 16px; margin-bottom: 20px; }
#right-col { display: flex; flex-direction: column; gap: 0; }
/* ── P&L ── */
.pl-section { margin-bottom: 18px; }
.pl-section-title {
font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid var(--border);
}
.pl-row { display: flex; align-items: center; justify-content: space-between; padding: 5px 4px; border-radius: 5px; }
.pl-row:hover { background: var(--navy-05); }
.pl-row-name { font-size: .86rem; color: var(--muted); display: flex; align-items: center; gap: 8px; }
.pl-row-code { font-size: .7rem; color: var(--muted); background: var(--navy-10); border-radius: 3px; padding: 1px 4px; }
.pl-row-amount { font-size: .87rem; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--navy); }
.pl-divider { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
.pl-total-row {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 8px; border-radius: 6px; background: var(--navy-05);
}
.pl-total-label { font-size: .88rem; font-weight: 700; color: var(--navy); }
.pl-total-amount { font-size: .95rem; font-weight: 800; font-variant-numeric: tabular-nums; }
.pl-total-amount.green { color: var(--green); }
.pl-result-row {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-radius: 8px; margin-top: 12px;
}
.pl-result-row.positive { background: rgba(52,199,89,.08); border: 1px solid rgba(52,199,89,.2); }
.pl-result-row.negative { background: rgba(255,59,48,.06); border: 1px solid rgba(255,59,48,.2); }
.pl-result-label { font-size: .9rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; color: var(--navy); }
.pl-result-amount { font-size: 1.25rem; font-weight: 800; font-variant-numeric: tabular-nums; }
.pl-result-amount.green { color: var(--green); }
.pl-result-amount.red { color: var(--red); }
.pl-empty { text-align: center; padding: 32px; color: var(--muted); font-size: .9rem; }
.pl-empty-icon { font-size: 2rem; display: block; margin-bottom: 8px; }
/* ── Journal ── */
.journal-empty { text-align: center; padding: 20px; color: var(--muted); font-size: .87rem; }
.journal-item {
display: flex; align-items: center; gap: 10px; padding: 8px 0;
border-bottom: 1px solid var(--border);
text-align: left; color: var(--navy);
}
.journal-item:last-child { border-bottom: none; }
.journal-date { font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; min-width: 76px; }
.journal-desc { flex: 1; font-size: .86rem; color: var(--navy); }
.journal-tag { font-size: .7rem; padding: 1px 5px; border-radius: 3px; background: var(--amber-10); color: var(--amber); font-weight: 600; }
.journal-amount { font-size: .88rem; font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
.journal-number { font-size: .72rem; color: var(--muted); background: var(--navy-10); border-radius: 4px; padding: 2px 6px; flex-shrink: 0; }
/* ── Period status ── */
.period-status-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.period-badge {
display: inline-flex; align-items: center; gap: 5px;
font-size: .78rem; font-weight: 600; padding: 4px 10px; border-radius: 20px;
}
.period-badge.open { background: rgba(52,199,89,.12); color: #1a9e40; }
.period-badge.review { background: rgba(200,118,58,.15); color: var(--amber); }
.period-badge.approved { background: rgba(0,30,80,.1); color: var(--navy); }
.period-badge.closed { background: #f0eee9; color: var(--muted); }
.btn-outline {
border: 1.5px solid var(--border); background: transparent;
padding: 7px 14px; font-size: .82rem; font-weight: 600; color: var(--navy);
border-radius: 8px; transition: border-color .15s, background .15s;
}
.btn-outline:hover { border-color: var(--navy); background: var(--navy-05); }
.btn-outline.danger { border-color: var(--red); color: var(--red); }
.btn-outline.danger:hover { background: rgba(255,59,48,.06); }
.period-detail { font-size: .8rem; color: var(--muted); margin-top: 4px; }
/* ── New Entry section ── */
#new-entry-section {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow); margin-bottom: 20px; overflow: hidden;
}
#new-entry-toggle {
width: 100%; display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; background: transparent; border: none; cursor: pointer;
text-align: left;
}
#new-entry-toggle:hover { background: var(--navy-05); }
.new-entry-title { font-size: .95rem; font-weight: 700; color: var(--navy); display: flex; align-items: center; gap: 8px; }
.toggle-chevron { font-size: .8rem; color: var(--muted); transition: transform .2s; }
.toggle-chevron.open { transform: rotate(180deg); }
#new-entry-body { display: none; padding: 18px; border-top: 1px solid var(--border); }
#new-entry-body.open { display: block; }
.form-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.form-group { display: flex; flex-direction: column; gap: 5px; }
.form-label { font-size: .78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.form-input {
border: 1.5px solid var(--border); border-radius: 8px; padding: 9px 12px;
font-size: .9rem; color: var(--navy); background: var(--bg); outline: none; transition: border-color .15s;
}
.form-input:focus { border-color: var(--amber); }
.form-select {
border: 1.5px solid var(--border); border-radius: 8px; padding: 9px 12px;
font-size: .9rem; color: var(--navy); background: var(--bg); outline: none;
cursor: pointer;
}
.form-select:focus { border-color: var(--amber); }
.entry-lines-header {
display: grid; grid-template-columns: 1fr 120px 120px 36px;
gap: 8px; padding: 0 0 6px; font-size: .72rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .05em; color: var(--muted);
}
#entry-lines { margin-bottom: 12px; }
.entry-line {
display: grid; grid-template-columns: 1fr 120px 120px 36px;
gap: 8px; margin-bottom: 8px; align-items: start;
}
.account-select-wrap { position: relative; }
.account-input {
border: 1.5px solid var(--border); border-radius: 8px; padding: 8px 10px;
font-size: .88rem; color: var(--navy); background: var(--bg); outline: none;
width: 100%; transition: border-color .15s;
}
.account-input:focus { border-color: var(--amber); }
.account-dropdown {
position: absolute; top: 100%; left: 0; right: 0; z-index: 50;
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
box-shadow: var(--shadow); max-height: 200px; overflow-y: auto; display: none;
}
.account-dropdown.open { display: block; }
.account-option {
padding: 8px 10px; font-size: .86rem; cursor: pointer; display: flex; align-items: center; gap: 8px;
}
.account-option:hover, .account-option.focused { background: var(--navy-05); }
.account-option-code { font-size: .75rem; color: var(--muted); background: var(--navy-10); border-radius: 4px; padding: 1px 5px; flex-shrink: 0; }
.num-input {
border: 1.5px solid var(--border); border-radius: 8px; padding: 8px 10px;
font-size: .88rem; color: var(--navy); background: var(--bg); outline: none;
text-align: right; width: 100%; transition: border-color .15s; font-variant-numeric: tabular-nums;
}
.num-input:focus { border-color: var(--amber); }
.remove-line-btn {
background: transparent; border: 1.5px solid var(--border); border-radius: 6px;
color: var(--muted); font-size: .85rem; width: 32px; height: 36px; display: flex;
align-items: center; justify-content: center; transition: border-color .15s, color .15s;
}
.remove-line-btn:hover { border-color: var(--red); color: var(--red); }
#add-line-btn {
background: transparent; border: 1.5px dashed var(--border);
border-radius: 8px; color: var(--muted); font-size: .85rem; padding: 8px;
width: 100%; margin-bottom: 14px; transition: border-color .15s, color .15s;
}
#add-line-btn:hover { border-color: var(--amber); color: var(--amber); }
.balance-indicator {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-radius: 8px; background: var(--navy-05);
border: 1.5px solid var(--border); margin-bottom: 14px;
}
.balance-indicator.balanced { background: rgba(52,199,89,.08); border-color: rgba(52,199,89,.3); }
.balance-indicator.unbalanced { background: rgba(255,59,48,.05); border-color: rgba(255,59,48,.2); }
.balance-amounts { display: flex; gap: 20px; font-size: .83rem; }
.balance-amounts span { font-variant-numeric: tabular-nums; }
#balance-status-text { font-size: .83rem; color: var(--muted); }
.btn-primary {
background: var(--navy); color: #fff; border: none; border-radius: 8px;
padding: 10px 20px; font-size: .9rem; font-weight: 600; transition: background .15s;
}
.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: 8px; padding: 10px 20px; font-size: .9rem; font-weight: 600;
transition: border-color .15s, color .15s;
}
.btn-secondary:hover { border-color: var(--navy); color: var(--navy); }
.entry-feedback { font-size: .85rem; color: var(--muted); padding: 8px 0; display: none; }
.entry-feedback.show { display: block; color: var(--green); }
.entry-feedback.show.error { color: var(--red); }
.form-actions { display: flex; align-items: center; gap: 10px; }
/* ── Footer ── */
#footer {
max-width: 1260px; margin: 20px auto 0; padding: 14px 24px;
border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between;
font-size: .75rem; color: var(--muted);
}
.footer-period-badge { display: flex; align-items: center; gap: 6px; }
/* ── Quick actions ── */
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.qa-item {
display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 12px 8px;
background: var(--navy-05); border: 1.5px solid var(--border); border-radius: 10px;
color: var(--navy); font-size: .8rem; font-weight: 600; text-align: center;
transition: border-color .15s, background .15s;
}
.qa-item:hover { border-color: var(--amber); background: var(--amber-10); }
.qa-item.highlight { border-color: var(--amber); background: var(--amber-10); }
.qa-icon { font-size: 1.3rem; }
.qa-label { font-size: .78rem; }
/* ── Modal ── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 200;
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.modal-overlay.hidden { display: none; }
.modal-box {
background: var(--card); border-radius: var(--radius); box-shadow: 0 8px 40px rgba(0,30,80,.2);
width: 100%; max-width: 600px; overflow: hidden;
max-height: 90vh; display: flex; flex-direction: column;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 22px; border-bottom: 1px solid var(--border); background: var(--navy-05);
}
.modal-title { font-size: 1rem; font-weight: 700; color: var(--navy); }
.modal-close { background: none; border: none; font-size: 1.2rem; color: var(--muted); cursor: pointer; line-height: 1; }
.modal-body { padding: 22px; overflow-y: auto; flex: 1; }
.modal-footer { padding: 14px 22px; border-top: 1px solid var(--border); display: flex; gap: 10px; justify-content: flex-end; }
/* ── Invoice list ── */
.invoice-table { width: 100%; border-collapse: collapse; font-size: .86rem; }
.invoice-table th {
text-align: left; font-size: .72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: var(--muted); padding: 8px 10px; border-bottom: 2px solid var(--border);
}
.invoice-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); }
.invoice-table tr:hover td { background: var(--navy-05); }
.invoice-status {
display: inline-flex; align-items: center; gap: 4px;
font-size: .72rem; font-weight: 700; padding: 2px 8px; border-radius: 10px;
}
.invoice-status.draft { background: var(--navy-10); color: var(--muted); }
.invoice-status.sent { background: rgba(200,118,58,.15); color: var(--amber); }
.invoice-status.paid { background: rgba(52,199,89,.12); color: #1a9e40; }
.invoice-status.overdue { background: rgba(255,59,48,.1); color: var(--red); }
.invoice-empty { text-align: center; padding: 32px; color: var(--muted); font-size: .9rem; }
/* ── VAT grid ── */
.vat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
.vat-card {
background: var(--navy-05); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px;
}
.vat-ruta { font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
.vat-label { font-size: .83rem; color: var(--muted); margin-bottom: 6px; }
.vat-amount { font-size: 1.15rem; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--navy); }
.vat-total {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-radius: 10px;
background: var(--navy); color: #fff; margin-bottom: 12px;
}
.vat-total-label { font-size: .88rem; font-weight: 700; }
.vat-total-amount { font-size: 1.3rem; font-weight: 800; font-variant-numeric: tabular-nums; }
.vat-total-amount.to-pay { color: #ff9f7a; }
.vat-total-amount.to-reclaim { color: #7aff9f; }
/* ── Period close checklist ── */
.checklist { list-style: none; }
.checklist li {
display: flex; align-items: center; gap: 10px; padding: 10px 0;
border-bottom: 1px solid var(--border); font-size: .88rem;
}
.checklist li:last-child { border-bottom: none; }
.check-icon { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .8rem; flex-shrink: 0; }
.check-icon.ok { background: rgba(52,199,89,.15); color: var(--green); }
.check-icon.warn { background: rgba(255,59,48,.1); color: var(--red); }
.check-icon.pending { background: var(--navy-10); color: var(--muted); }
.check-text { flex: 1; }
.check-val { font-size: .8rem; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
/* ── Misc ── */
.tag-pill {
display: inline-flex; align-items: center; gap: 4px; font-size: .73rem; font-weight: 600;
padding: 2px 8px; border-radius: 10px; background: var(--navy-10); color: var(--navy);
}
.period-workflow-steps {
display: flex; align-items: center; gap: 4px; font-size: .78rem; flex-wrap: wrap; margin-bottom: 12px;
}
.wf-step {
padding: 4px 10px; border-radius: 5px; background: var(--navy-10); color: var(--muted);
font-weight: 600;
}
.wf-step.active { background: var(--amber); color: #fff; }
.wf-step.done { background: rgba(52,199,89,.15); color: var(--green); }
.wf-arrow { color: var(--muted); font-size: .9rem; }
@media (max-width: 900px) {
#content-row { grid-template-columns: 1fr; }
#kpi-row { grid-template-columns: repeat(2,1fr); }
.form-row { grid-template-columns: 1fr; }
.vat-grid { grid-template-columns: 1fr; }
}
/* ── Tooltip ── */
.tooltip-wrap { position: relative; display: inline-block; }
.tooltip-wrap:hover .tooltip-box { display: block; }
.tooltip-box {
display: none; position: absolute; bottom: 110%; left: 50%; transform: translateX(-50%);
background: var(--navy); color: #fff; font-size: .73rem; padding: 5px 10px; border-radius: 6px;
white-space: nowrap; z-index: 99; pointer-events: none;
}
</style>
</head>
<body>
<!-- ── Topbar ── -->
<div id="topbar">
<div class="topbar-brand">
<div class="brand-logo"></div>
<div>
<span class="brand-name">Ouroboros Finance</span>
<span class="brand-sep">·</span>
<span id="company-name">Laddar…</span>
</div>
</div>
<!-- Tab navigation -->
<div id="tab-nav">
<button class="tab-btn active" onclick="switchTab('overview')">📊 Översikt</button>
<button class="tab-btn" onclick="switchTab('invoices')">🧾 Fakturor</button>
<button class="tab-btn" onclick="switchTab('vat')">🧮 Moms</button>
<button class="tab-btn" onclick="switchTab('period')">📅 Periodstängning</button>
</div>
<div class="topbar-center">
<span class="period-label">Period</span>
<select id="period-select"></select>
</div>
<div class="topbar-right">
<div id="data-status">
<span class="status-dot" id="status-dot"></span>
<span id="status-text">Ansluter…</span>
</div>
<button class="btn-amber" id="btn-new-entry-top" onclick="openNewEntry()">
<span></span> Ny verifikation
</button>
<button class="btn-icon" title="Inställningar"></button>
</div>
</div>
<!-- ── MAIN ── -->
<div id="main">
<!-- ═══ TAB: OVERVIEW ═══ -->
<div id="tab-overview" class="tab-section active">
<!-- KPI row -->
<div id="kpi-row">
<div class="kpi-card" id="kpi-cash">
<div class="kpi-header"><span class="kpi-icon">🏦</span><span class="kpi-trend flat" id="kpi-cash-trend"></span></div>
<div class="kpi-value" id="kpi-cash-val"></div>
<div class="kpi-label">Kassa</div>
<div class="kpi-sub" id="kpi-cash-sub">Bankkonton 19101940</div>
</div>
<div class="kpi-card" id="kpi-rev">
<div class="kpi-header"><span class="kpi-icon">📈</span><span class="kpi-trend flat" id="kpi-rev-trend"></span></div>
<div class="kpi-value" id="kpi-rev-val"></div>
<div class="kpi-label">Intäkter YTD</div>
<div class="kpi-sub" id="kpi-rev-sub">Konton 30003999</div>
</div>
<div class="kpi-card" id="kpi-exp">
<div class="kpi-header"><span class="kpi-icon">📉</span><span class="kpi-trend flat" id="kpi-exp-trend"></span></div>
<div class="kpi-value" id="kpi-exp-val"></div>
<div class="kpi-label">Kostnader YTD</div>
<div class="kpi-sub">Konton 40008999</div>
</div>
<div class="kpi-card" id="kpi-net">
<div class="kpi-header"><span class="kpi-icon">💰</span><span class="kpi-trend flat" id="kpi-net-trend"></span></div>
<div class="kpi-value" id="kpi-net-val"></div>
<div class="kpi-label">Resultat YTD</div>
<div class="kpi-sub">Intäkter Kostnader</div>
</div>
</div>
<!-- Two-col -->
<div id="content-row">
<!-- Left: P&L -->
<div class="card">
<div class="card-header">
<span class="card-title">📊 Resultaträkning</span>
<span class="card-badge" id="pl-period-badge"></span>
</div>
<div class="card-body" id="pl-body">
<div style="text-align:center;padding:40px;color:var(--muted);">Laddar…</div>
</div>
</div>
<!-- Right col -->
<div id="right-col">
<!-- Quick actions -->
<div class="card">
<div class="card-header"><span class="card-title">⚡ Snabbåtgärder</span></div>
<div class="card-body">
<div class="qa-grid">
<button class="qa-item highlight" onclick="openNewEntry()"><span class="qa-icon">📝</span><span class="qa-label">Skapa verifikation</span></button>
<button class="qa-item" onclick="switchTab('invoices');openNewInvoice()"><span class="qa-icon">🧾</span><span class="qa-label">Ny faktura</span></button>
<button class="qa-item" onclick="showBalanceSheet()"><span class="qa-icon">📊</span><span class="qa-label">Balansräkning</span></button>
<button class="qa-item" id="btn-export-sie" onclick="exportSIE()"><span class="qa-icon">📤</span><span class="qa-label">Exportera SIE4</span></button>
</div>
</div>
</div>
<!-- Journal -->
<div class="card">
<div class="card-header">
<span class="card-title">🗂 Senaste verifikationer</span>
<a href="#" style="font-size:.8rem;color:var(--amber);" onclick="viewAllJournal(event)">Visa alla →</a>
</div>
<div class="card-body" id="journal-body"><div class="journal-empty">Laddar…</div></div>
</div>
<!-- Period status -->
<div class="card">
<div class="card-header"><span class="card-title">📅 Periodstatus</span></div>
<div class="card-body">
<div class="period-status-row">
<div>
<div class="period-badge open" id="period-status-badge"><span></span> <span id="period-status-text">Öppen</span></div>
<div class="period-detail" id="period-detail-text">Period öppen för bokföring</div>
</div>
<button class="btn-outline" id="period-action-btn" onclick="periodAction()">Skicka för granskning</button>
</div>
</div>
</div>
</div>
</div>
<!-- New Entry section -->
<div id="new-entry-section">
<button id="new-entry-toggle" onclick="toggleNewEntry()">
<span class="new-entry-title"><span style="color:var(--amber);font-size:1.1rem;"></span> Ny verifikation</span>
<span class="toggle-chevron" id="new-entry-chevron"></span>
</button>
<div id="new-entry-body">
<div class="form-row">
<div class="form-group"><label class="form-label">Datum</label><input type="date" class="form-input" id="entry-date"></div>
<div class="form-group"><label class="form-label">Beskrivning</label><input type="text" class="form-input" id="entry-desc" placeholder="t.ex. Faktura 101"></div>
<div class="form-group"><label class="form-label">Referens (valfri)</label><input type="text" class="form-input" id="entry-ref" placeholder="t.ex. FAK-2026-001"></div>
</div>
<div class="entry-lines-header"><span>Konto</span><span style="text-align:right">Debet (kr)</span><span style="text-align:right">Kredit (kr)</span><span></span></div>
<div id="entry-lines"></div>
<button id="add-line-btn" onclick="addEntryLine()"> Lägg till rad</button>
<div class="balance-indicator" id="balance-indicator">
<div class="balance-amounts">
<span>Debet: <strong id="bal-debet">0 kr</strong></span>
<span>Kredit: <strong id="bal-kredit">0 kr</strong></span>
</div>
<span id="balance-status-text">Ange konteringsrader</span>
</div>
<div class="form-actions">
<button class="btn-primary" id="save-entry-btn" onclick="saveEntry()">💾 Spara verifikation</button>
<button class="btn-secondary" onclick="resetNewEntry()">Rensa</button>
<span class="entry-feedback" id="entry-feedback"></span>
</div>
</div>
</div>
</div><!-- /tab-overview -->
<!-- ═══ TAB: FAKTUROR ═══ -->
<div id="tab-invoices" class="tab-section">
<div class="card">
<div class="card-header">
<span class="card-title">🧾 Fakturor</span>
<button class="btn-amber" onclick="openNewInvoice()"> Ny faktura</button>
</div>
<div class="card-body">
<div id="invoice-summary" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px;"></div>
<div id="invoice-list-wrap">
<div class="invoice-empty" id="invoice-empty-msg">Laddar fakturor…</div>
<table class="invoice-table" id="invoice-table" style="display:none;">
<thead>
<tr>
<th>Nr</th><th>Kund</th><th>Datum</th><th>Förfaller</th><th>Belopp exkl moms</th><th>Moms</th><th>Totalt</th><th>Status</th>
</tr>
</thead>
<tbody id="invoice-tbody"></tbody>
</table>
</div>
</div>
</div>
</div><!-- /tab-invoices -->
<!-- ═══ TAB: MOMS ═══ -->
<div id="tab-vat" class="tab-section">
<div class="card">
<div class="card-header">
<span class="card-title">🧮 Momsdeklarationsunderlag</span>
<div style="display:flex;gap:8px;align-items:center;">
<select class="form-select" id="vat-period-select" style="font-size:.82rem;padding:5px 10px;" onchange="loadVAT()">
<option value="Q1">Q1 (JanMar) 2026</option>
<option value="Q2" selected>Q2 (AprJun) 2026</option>
<option value="Q3">Q3 (JulSep) 2026</option>
<option value="Q4">Q4 (OktDec) 2026</option>
</select>
<button class="btn-amber" onclick="exportVATReport()">📄 Skicka till KPMG</button>
</div>
</div>
<div class="card-body" id="vat-body">
<div style="text-align:center;padding:40px;color:var(--muted);">Laddar momsdata…</div>
</div>
</div>
</div><!-- /tab-vat -->
<!-- ═══ TAB: PERIOD CLOSE ═══ -->
<div id="tab-period" class="tab-section">
<div class="card">
<div class="card-header">
<span class="card-title">📅 Periodstängning — <span id="period-close-label"></span></span>
<div class="period-badge open" id="period-close-badge"><span></span> <span id="period-close-status-text">Öppen</span></div>
</div>
<div class="card-body">
<!-- Workflow steps -->
<div class="period-workflow-steps" id="period-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>
<!-- Checklist -->
<div id="period-checklist-wrap">
<div style="font-size:.88rem;font-weight:700;color:var(--navy);margin-bottom:12px;">Checklista innan stängning</div>
<ul class="checklist" id="period-checklist">
<li><span class="check-icon pending" id="chk-balance-icon"></span><span class="check-text">Balansräkning balanserar (debet = kredit)</span><span class="check-val" id="chk-balance-val"></span></li>
<li><span class="check-icon pending" id="chk-interimIcon"></span><span class="check-text">Konto 1689 (Förutb. kostn.) = 0</span><span class="check-val" id="chk-interim-val"></span></li>
<li><span class="check-icon pending" id="chk-vat-icon"></span><span class="check-text">Momsbelopp beräknat (konto 2611 kontrollerat)</span><span class="check-val" id="chk-vat-val"></span></li>
<li><span class="check-icon pending" id="chk-entries-icon"></span><span class="check-text">Inga utkast-verifikationer (status = draft)</span><span class="check-val" id="chk-entries-val"></span></li>
</ul>
</div>
<div id="period-actions" style="margin-top:16px;display:flex;gap:10px;flex-wrap:wrap;"></div>
<div id="period-close-feedback" style="margin-top:10px;font-size:.85rem;"></div>
<!-- Snapshot / Trial Balance -->
<div id="period-snapshot-wrap" style="display:none;margin-top:20px;">
<div style="font-size:.88rem;font-weight:700;color:var(--navy);margin-bottom:10px;">📸 Trial Balance Snapshot</div>
<div id="period-snapshot-content"></div>
</div>
</div>
</div>
</div><!-- /tab-period -->
</div><!-- /main -->
<!-- ── Footer ── -->
<div id="footer">
<span id="footer-brand">Ouroboros Finance · LandveX AB · 2026</span>
<div class="footer-period-badge">
<span id="footer-period-badge" class="period-badge open"><span></span> <span>Öppen</span></span>
<span id="footer-entries-count" style="margin-left:8px;"></span>
</div>
</div>
<!-- ── New Invoice Modal ── -->
<div id="modal-invoice" class="modal-overlay hidden">
<div class="modal-box">
<div class="modal-header">
<span class="modal-title">🧾 Ny faktura</span>
<button class="modal-close" onclick="closeModal('modal-invoice')"></button>
</div>
<div class="modal-body">
<div class="form-row" style="grid-template-columns:1fr 1fr;">
<div class="form-group"><label class="form-label">Kund</label><input type="text" class="form-input" id="inv-customer" placeholder="Kundnamn"></div>
<div class="form-group"><label class="form-label">Fakturadatum</label><input type="date" class="form-input" id="inv-date"></div>
</div>
<div class="form-row" style="grid-template-columns:1fr 1fr;">
<div class="form-group"><label class="form-label">Förfallodatum</label><input type="date" class="form-input" id="inv-due"></div>
<div class="form-group"><label class="form-label">Belopp exkl. moms (kr)</label><input type="number" class="form-input" id="inv-amount" placeholder="0.00" min="0" step="0.01"></div>
</div>
<div class="form-row" style="grid-template-columns:1fr 1fr;">
<div class="form-group">
<label class="form-label">Momssats</label>
<select class="form-select" id="inv-vat-rate">
<option value="25">25% (standard)</option>
<option value="12">12% (livsmedel, restaurang)</option>
<option value="6">6% (tidningar, böcker)</option>
<option value="0">0% (momsfri)</option>
</select>
</div>
<div class="form-group"><label class="form-label">Referens/OrderNr</label><input type="text" class="form-input" id="inv-ref" placeholder="t.ex. ORD-2026-001"></div>
</div>
<div class="form-group"><label class="form-label">Beskrivning</label><input type="text" class="form-input" id="inv-desc" placeholder="Tjänstebeskrivning"></div>
<div id="inv-preview" style="margin-top:14px;padding:12px;background:var(--navy-05);border-radius:8px;font-size:.85rem;display:none;"></div>
<div id="inv-feedback" class="entry-feedback" style="margin-top:10px;"></div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeModal('modal-invoice')">Avbryt</button>
<button class="btn-primary" id="inv-save-btn" onclick="saveInvoice()">💾 Spara &amp; bokför</button>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// CONFIG & STATE
// ═══════════════════════════════════════════════════════════
const API_BASE = 'http://127.0.0.1:3250';
const FISCAL_YEAR = 2026;
const TIMEOUT_MS = 8000;
const INTERNAL_TOKEN = 'wavult-amos-internal-2026';
const TENANT_ID = 'landvex';
const DEMO = {
company: "LandveX AB",
period: "2026-06",
};
const DEMO_ACCOUNTS = [
{account_number:"1910", name:"Kassa"},{account_number:"1920", name:"PlusGiro"},
{account_number:"1930", name:"Bankkonto"},{account_number:"1510", name:"Kundfordringar"},
{account_number:"2081", name:"Aktiekapital"},{account_number:"2611", name:"Utgående moms 25%"},
{account_number:"2615", name:"Utgående moms 12%"},{account_number:"2645", name:"Beräknad ingående moms"},
{account_number:"2650", name:"Redovisningskonto moms"},{account_number:"3000", name:"Försäljning varor 25%"},
{account_number:"3010", name:"Försäljning tjänster 25%"},{account_number:"3020", name:"Försäljning tjänster 12%"},
{account_number:"5010", name:"Lokalhyra"},{account_number:"7010", name:"Löner"},
{account_number:"7510", name:"Arbetsgivaravgifter"},{account_number:"1689", name:"Förutbetalda kostnader"},
];
let state = {
usingDemo: false, token: null, period: null,
accounts: [], trialBalance: [], journal: [], periods: [],
companyName: DEMO.company, prevPeriodData: null,
invoices: [], // local invoice store
};
// ═══════════════════════════════════════════════════════════
// TAB NAVIGATION
// ═══════════════════════════════════════════════════════════
function switchTab(tab) {
document.querySelectorAll('.tab-section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
const map = { overview:0, invoices:1, vat:2, period:3 };
document.querySelectorAll('.tab-btn')[map[tab] ?? 0].classList.add('active');
if (tab === 'vat') loadVAT();
if (tab === 'period') loadPeriodClose();
if (tab === 'invoices') renderInvoiceList();
}
// ═══════════════════════════════════════════════════════════
// TOKEN & PERIOD
// ═══════════════════════════════════════════════════════════
function getToken() {
const params = new URLSearchParams(window.location.search);
return params.get('token') || localStorage.getItem('ouroboros_finance_token') || INTERNAL_TOKEN;
}
function getCurrentPeriod() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`;
}
function populatePeriodDropdown() {
const sel = document.getElementById('period-select');
sel.innerHTML = '';
const months = ['Jan','Feb','Mar','Apr','Maj','Jun','Jul','Aug','Sep','Okt','Nov','Dec'];
for (let m = 1; m <= 12; m++) {
const val = `${FISCAL_YEAR}-${String(m).padStart(2,'0')}`;
const opt = document.createElement('option');
opt.value = val;
opt.textContent = `${months[m-1]} ${FISCAL_YEAR}`;
if (val === state.period) opt.selected = true;
sel.appendChild(opt);
}
sel.onchange = () => { state.period = sel.value; loadData(); };
}
// ═══════════════════════════════════════════════════════════
// FETCH HELPERS
// ═══════════════════════════════════════════════════════════
async function apiFetch(path, method = 'GET', data) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const headers = { 'x-internal-source': 'ouroboros-system', 'x-tenant-id': TENANT_ID };
const tok = state.token || INTERNAL_TOKEN;
if (tok) headers['Authorization'] = `Bearer ${tok}`;
const opts = { headers, signal: controller.signal };
if (method !== 'GET') {
opts.method = method;
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(data);
}
const res = await fetch(API_BASE + path, opts);
clearTimeout(timer);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch(e) { clearTimeout(timer); throw e; }
}
// ═══════════════════════════════════════════════════════════
// LOAD DATA
// ═══════════════════════════════════════════════════════════
async function loadData() {
setStatus('loading');
try {
const [accounts, tb, journal, periods] = await Promise.all([
apiFetch('/api/ledger/accounts'),
apiFetch(`/api/ledger/trial-balance?fiscal_year=${FISCAL_YEAR}&period=${state.period}`),
apiFetch(`/api/ledger/journal?fiscal_year=${FISCAL_YEAR}&period=${state.period}`),
apiFetch(`/api/ledger/periods?fiscal_year=${FISCAL_YEAR}`),
]);
state.usingDemo = false;
state.accounts = accounts.accounts || accounts || [];
state.trialBalance = tb.accounts || tb || [];
state.journal = journal.entries || journal || [];
state.periods = periods.periods || periods || [];
if (accounts.company) state.companyName = accounts.company;
setStatus('live');
} catch(e) {
state.usingDemo = true;
state.accounts = DEMO_ACCOUNTS;
state.trialBalance = buildDemoTrialBalance();
state.journal = buildDemoJournal();
state.periods = [];
state.companyName = DEMO.company;
setStatus('demo');
}
renderAll();
}
function buildDemoTrialBalance() {
return [
{account_number:"1930", name:"Bankkonto", type:"asset", debit:500000, credit:0, balance:500000},
{account_number:"1510", name:"Kundfordringar", type:"asset", debit:120000, credit:0, balance:120000},
{account_number:"2081", name:"Aktiekapital", type:"equity", debit:0, credit:500000, balance:-500000},
{account_number:"2611", name:"Utgående moms 25%", type:"liability", debit:0, credit:30000, balance:-30000},
{account_number:"2645", name:"Beräknad ingående moms", type:"asset", debit:5000, credit:0, balance:5000},
{account_number:"3010", name:"Försäljning tjänster", type:"revenue", debit:0, credit:120000, balance:-120000},
{account_number:"5010", name:"Lokalhyra", type:"expense", debit:20000, credit:0, balance:20000},
];
}
function buildDemoJournal() {
return [
{id:1, date:"2026-06-01", description:"Aktiekapital insatt", reference:"VER-001", lines:[
{account_number:"1930", debit:500000, credit:0},{account_number:"2081", debit:0, credit:500000}
]},
{id:2, date:"2026-06-05", description:"Faktura #101 konsulttjänst", reference:"FAK-101", lines:[
{account_number:"1510", debit:150000, credit:0},
{account_number:"3010", debit:0, credit:120000},
{account_number:"2611", debit:0, credit:30000}
]}
];
}
// ═══════════════════════════════════════════════════════════
// STATUS
// ═══════════════════════════════════════════════════════════
function setStatus(s) {
const dot = document.getElementById('status-dot');
const txt = document.getElementById('status-text');
dot.className = 'status-dot';
if (s === 'live') { dot.classList.add('live'); txt.textContent = 'Live'; }
else if (s === 'demo') { dot.classList.add('demo'); txt.textContent = 'Demo-data'; }
else if (s === 'loading') { txt.textContent = 'Laddar…'; }
else { dot.classList.add('error'); txt.textContent = 'Ingen anslutning'; }
}
// ═══════════════════════════════════════════════════════════
// RENDER ALL
// ═══════════════════════════════════════════════════════════
function renderAll() {
const tb = state.trialBalance;
const cash = tb.filter(a => /^19[0-9]{2}$/.test(a.account_number)).reduce((s,a) => s + parseFloat(a.balance || 0), 0);
const revenue = tb.filter(a => /^3[0-9]{3}$/.test(a.account_number)).reduce((s,a) => s + Math.abs(parseFloat(a.balance || 0)), 0);
const expenses = tb.filter(a => /^[4-8][0-9]{3}$/.test(a.account_number)).reduce((s,a) => s + Math.abs(parseFloat(a.balance || 0)), 0);
const net = revenue - expenses;
const receivables = parseFloat((tb.find(a => a.account_number === '1510') || {}).balance || 0);
renderKPI('kpi-cash', 'kpi-cash-val', cash, cash >= 0 ? 'green' : 'red');
renderKPI('kpi-rev', 'kpi-rev-val', revenue, 'amber');
renderKPI('kpi-exp', 'kpi-exp-val', expenses, 'navy');
renderKPI('kpi-net', 'kpi-net-val', net, net >= 0 ? 'green' : 'red');
document.getElementById('company-name').textContent = state.companyName;
document.getElementById('pl-period-badge').textContent = state.period ? formatPeriodLabel(state.period) : '—';
renderPL(tb, revenue, expenses, net);
renderJournal();
renderPeriodStatus();
document.getElementById('footer-brand').textContent = `Ouroboros Finance · ${state.companyName} · ${FISCAL_YEAR}`;
document.getElementById('footer-entries-count').textContent = state.journal.length > 0 ? `${state.journal.length} verifikationer` : '';
populatePeriodDropdown();
buildAccountsList();
}
function renderKPI(cardId, valId, amount, color) {
document.getElementById(cardId).className = `kpi-card ${color}`;
const val = document.getElementById(valId);
val.className = `kpi-value ${color}`;
val.textContent = formatAmount(amount);
}
function formatPeriodLabel(p) {
const months = ['Jan','Feb','Mar','Apr','Maj','Jun','Jul','Aug','Sep','Okt','Nov','Dec'];
const parts = p.split('-');
return `${months[parseInt(parts[1])-1]} ${parts[0]}`;
}
// ═══════════════════════════════════════════════════════════
// P&L RENDER
// ═══════════════════════════════════════════════════════════
function renderPL(tb, totalRevenue, totalExpenses, net) {
const body = document.getElementById('pl-body');
const revenueAccounts = tb.filter(a => /^3[0-9]{3}$/.test(a.account_number));
const expenseAccounts = tb.filter(a => /^[4-8][0-9]{3}$/.test(a.account_number));
if (totalRevenue === 0 && totalExpenses === 0) {
body.innerHTML = `<div class="pl-empty"><span class="pl-empty-icon">📭</span><strong>Inga transaktioner än</strong><br>Skapa din första verifikation för att se resultaträkningen</div>`;
return;
}
let html = '<div class="pl-section">';
html += '<div class="pl-section-title">Intäkter</div>';
const revRows = revenueAccounts.filter(a => Math.abs(parseFloat(a.balance || 0)) > 0);
revRows.forEach(a => { html += `<div class="pl-row"><span class="pl-row-name">${escHtml(a.name)}<span class="pl-row-code">${a.account_number}</span></span><span class="pl-row-amount">${formatAmount(Math.abs(parseFloat(a.balance||0)))} kr</span></div>`; });
html += `<div class="pl-divider"></div><div class="pl-total-row"><span class="pl-total-label">SUMMA INTÄKTER</span><span class="pl-total-amount ${totalRevenue>0?'green':''}">${formatAmount(totalRevenue)} kr</span></div></div>`;
html += '<div class="pl-section"><div class="pl-section-title">Kostnader</div>';
const expRows = expenseAccounts.filter(a => Math.abs(parseFloat(a.balance || 0)) > 0);
expRows.forEach(a => { html += `<div class="pl-row"><span class="pl-row-name">${escHtml(a.name)}<span class="pl-row-code">${a.account_number}</span></span><span class="pl-row-amount">${formatAmount(Math.abs(parseFloat(a.balance||0)))} kr</span></div>`; });
html += `<div class="pl-divider"></div><div class="pl-total-row"><span class="pl-total-label">SUMMA KOSTNADER</span><span class="pl-total-amount">${formatAmount(totalExpenses)} kr</span></div></div>`;
const resClass = net >= 0 ? 'positive' : 'negative', resColor = net >= 0 ? 'green' : 'red';
html += `<div class="pl-result-row ${resClass}"><span class="pl-result-label">🏁 Rörelseresultat</span><span class="pl-result-amount ${resColor}">${net>=0?'+':''}${formatAmount(net)} kr</span></div>`;
body.innerHTML = html;
}
// ═══════════════════════════════════════════════════════════
// JOURNAL RENDER
// ═══════════════════════════════════════════════════════════
function renderJournal() {
const body = document.getElementById('journal-body');
const entries = [...state.journal].sort((a,b) => new Date(b.date) - new Date(a.date)).slice(0,7);
if (!entries.length) { body.innerHTML = '<div class="journal-empty">Inga verifikationer denna period</div>'; return; }
let html = '';
entries.forEach(e => {
const amount = getEntryAmount(e);
const isInvoice = (e.reference || '').startsWith('FAK-');
html += `<div class="journal-item">
<span class="journal-number">#${e.id||'—'}</span>
<span class="journal-date">${e.date||''}</span>
<span class="journal-desc">${escHtml(e.description||'')} ${isInvoice?`<span class="journal-tag">FAKTURA</span>`:''}</span>
<span class="journal-amount">${amount>0?formatAmount(amount)+' kr':'—'}</span>
</div>`;
});
body.innerHTML = html;
}
function getEntryAmount(e) {
if (!e.lines) return 0;
return e.lines.reduce((s,l) => s + parseFloat(l.debit||0), 0);
}
// ═══════════════════════════════════════════════════════════
// PERIOD STATUS
// ═══════════════════════════════════════════════════════════
function renderPeriodStatus() {
const period = state.periods.find(p => p.period === state.period);
const status = period ? period.status : 'open';
const map = {
open: { cls:'open', label:'Öppen', detail:'Period öppen för bokföring', btn:'Skicka för granskning' },
review: { cls:'review', label:'Under granskning',detail:'Inväntar revisor', btn:'Godkänn period' },
approved: { cls:'approved', label:'Godkänd', detail:'Period godkänd', btn:'Stäng period' },
closed: { cls:'closed', label:'Stängd', detail:'Ingen bokföring möjlig', btn:'Öppna igen' },
};
const m = map[status] || map.open;
['period-status-badge','footer-period-badge'].forEach(id => { const b = document.getElementById(id); b.className = `period-badge ${m.cls}`; b.innerHTML = `<span>●</span> <span>${m.label}</span>`; });
document.getElementById('period-status-text').textContent = m.label;
document.getElementById('period-detail-text').textContent = m.detail;
document.getElementById('period-action-btn').textContent = m.btn;
}
// ═══════════════════════════════════════════════════════════
// FORMAT HELPERS
// ═══════════════════════════════════════════════════════════
function formatAmount(n) {
const abs = Math.abs(n);
const sign = n < 0 ? '' : '';
if (abs >= 10000) return sign + Math.round(abs).toLocaleString('sv-SE');
return sign + abs.toLocaleString('sv-SE', {maximumFractionDigits:2});
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function todayStr() { return new Date().toISOString().split('T')[0]; }
// ═══════════════════════════════════════════════════════════
// NEW ENTRY FORM
// ═══════════════════════════════════════════════════════════
let entryLineCount = 0, allAccounts = [];
function buildAccountsList() { allAccounts = state.accounts.length > 0 ? state.accounts : DEMO_ACCOUNTS; }
function openNewEntry() {
const body = document.getElementById('new-entry-body'), chev = document.getElementById('new-entry-chevron');
body.classList.add('open'); chev.classList.add('open');
document.getElementById('new-entry-section').scrollIntoView({behavior:'smooth', block:'start'});
}
function toggleNewEntry() {
const body = document.getElementById('new-entry-body'), chev = document.getElementById('new-entry-chevron');
body.classList.toggle('open'); chev.classList.toggle('open');
}
function resetNewEntry() {
document.getElementById('entry-date').value = todayStr();
document.getElementById('entry-desc').value = '';
document.getElementById('entry-ref').value = '';
document.getElementById('entry-lines').innerHTML = '';
entryLineCount = 0; addEntryLine(); addEntryLine(); updateBalance();
document.getElementById('entry-feedback').className = '';
document.getElementById('entry-feedback').textContent = '';
}
function addEntryLine() {
const id = ++entryLineCount;
const container = document.getElementById('entry-lines');
const div = document.createElement('div'); div.className = 'entry-line'; div.id = `line-${id}`;
div.innerHTML = `
<div class="account-select-wrap">
<input type="text" class="account-input" id="acc-${id}" placeholder="Sök konto…" autocomplete="off"
oninput="filterAccounts(${id})" onfocus="showDropdown(${id})" onblur="hideDropdown(${id})">
<div class="account-dropdown" id="drop-${id}"></div>
</div>
<input type="number" class="num-input" id="deb-${id}" placeholder="0" min="0" step="0.01" oninput="updateBalance()">
<input type="number" class="num-input" id="cre-${id}" placeholder="0" min="0" step="0.01" oninput="updateBalance()">
<button class="remove-line-btn" onclick="removeLine(${id})" title="Ta bort rad">✕</button>`;
container.appendChild(div); updateBalance();
}
function removeLine(id) { const el = document.getElementById(`line-${id}`); if (el) el.remove(); updateBalance(); }
let _selectedAccounts = {};
function filterAccounts(id) {
const input = document.getElementById(`acc-${id}`), drop = document.getElementById(`drop-${id}`);
const query = input.value.toLowerCase();
const matches = allAccounts.filter(a => a.account_number.includes(query) || (a.name||a.account_name||'').toLowerCase().includes(query)).slice(0,20);
renderDropdown(id, matches, drop); drop.classList.add('open');
}
function showDropdown(id) { filterAccounts(id); document.getElementById(`drop-${id}`).classList.add('open'); }
function hideDropdown(id) { setTimeout(() => { const d = document.getElementById(`drop-${id}`); if(d) d.classList.remove('open'); }, 180); }
function renderDropdown(id, accounts, drop) {
if (!accounts.length) { drop.innerHTML = `<div class="account-option" style="color:var(--muted);cursor:default">Inga konton hittades</div>`; return; }
drop.innerHTML = accounts.map(a => {
const name = a.name || a.account_name || '';
return `<div class="account-option" onmousedown="selectAccount(${id},'${a.account_number}','${escHtml(name).replace(/'/g,"\\'")}')"><span class="account-option-code">${a.account_number}</span><span>${escHtml(name)}</span></div>`;
}).join('');
}
function selectAccount(id, code, name) {
const input = document.getElementById(`acc-${id}`);
input.value = `${code} ${name}`; input.dataset.accountNumber = code;
_selectedAccounts[id] = {account_number: code, name};
document.getElementById(`drop-${id}`).classList.remove('open');
}
function updateBalance() {
let debit = 0, credit = 0;
document.querySelectorAll('.entry-line').forEach(line => {
const id = line.id.replace('line-','');
debit += parseFloat(document.getElementById(`deb-${id}`)?.value||0)||0;
credit += parseFloat(document.getElementById(`cre-${id}`)?.value||0)||0;
});
const ind = document.getElementById('balance-indicator');
document.getElementById('bal-debet').textContent = formatAmount(debit) + ' kr';
document.getElementById('bal-kredit').textContent = formatAmount(credit) + ' kr';
const balanced = debit > 0 && Math.abs(debit - credit) < 0.01;
ind.className = 'balance-indicator';
if (balanced) { ind.classList.add('balanced'); document.getElementById('balance-status-text').textContent = '✅ Verifikationen är balanserad'; }
else if (debit === 0 && credit === 0) { document.getElementById('balance-status-text').textContent = 'Ange konteringsrader'; }
else { ind.classList.add('unbalanced'); document.getElementById('balance-status-text').textContent = `⚠️ Differens: ${formatAmount(Math.abs(debit-credit))} kr`; }
}
async function saveEntry() {
const date = document.getElementById('entry-date').value;
const desc = document.getElementById('entry-desc').value.trim();
const ref = document.getElementById('entry-ref').value.trim();
const feedback = document.getElementById('entry-feedback');
if (!date || !desc) { feedback.textContent = '⚠️ Ange datum och beskrivning'; feedback.className = 'show error'; return; }
const lines = []; let debit = 0, credit = 0;
document.querySelectorAll('.entry-line').forEach(line => {
const id = line.id.replace('line-','');
const accInput = document.getElementById(`acc-${id}`);
const accNum = accInput?.dataset.accountNumber || accInput?.value.split(' ')[0];
const d = parseFloat(document.getElementById(`deb-${id}`)?.value||0)||0;
const c = parseFloat(document.getElementById(`cre-${id}`)?.value||0)||0;
if (accNum && (d > 0 || c > 0)) { lines.push({account_number:accNum, debit:d, credit:c}); debit+=d; credit+=c; }
});
if (lines.length < 2) { feedback.textContent = '⚠️ Minst 2 konteringsrader krävs'; feedback.className = 'show error'; return; }
if (Math.abs(debit - credit) > 0.01) { feedback.textContent = `⚠️ Inte balanserad (diff ${formatAmount(Math.abs(debit-credit))} kr)`; feedback.className = 'show error'; return; }
const btn = document.getElementById('save-entry-btn'); btn.disabled = true; btn.textContent = '⏳ Sparar…'; feedback.className = '';
const body = { date, description:desc, reference:ref||undefined, lines, fiscal_year:FISCAL_YEAR, period:state.period };
if (state.usingDemo) {
await new Promise(r => setTimeout(r, 400));
const newId = state.journal.length + 1;
state.journal.push({id:newId, date, description:desc, reference:ref, lines});
lines.forEach(l => {
let acc = state.trialBalance.find(a => a.account_number === l.account_number);
if (!acc) { const f = allAccounts.find(a => a.account_number === l.account_number); acc = {account_number:l.account_number, name:f?.name||l.account_number, debit:0, credit:0, balance:0}; state.trialBalance.push(acc); }
acc.debit=(acc.debit||0)+l.debit; acc.credit=(acc.credit||0)+l.credit; acc.balance=acc.debit-acc.credit;
});
feedback.textContent = `✅ Verifikation #${newId} sparad (demo)`; feedback.className = 'show';
btn.disabled=false; btn.textContent='💾 Spara verifikation'; renderAll(); return;
}
try {
const res = await apiFetch('/api/ledger/journal', 'POST', body);
const entryId = res.id || res.entry_id || '?';
try { await apiFetch(`/api/ledger/journal/${entryId}/post`, 'POST', {}); } catch(e) {}
feedback.textContent = `✅ Verifikation #${entryId} sparad`; feedback.className = 'show';
resetNewEntry(); await loadData();
} catch(e) { feedback.textContent = `❌ Fel: ${e.message}`; feedback.className = 'show error'; }
btn.disabled=false; btn.textContent='💾 Spara verifikation';
}
// ═══════════════════════════════════════════════════════════
// INVOICE MODULE
// ═══════════════════════════════════════════════════════════
// state.invoices holds local invoice objects (persisted in localStorage)
function loadInvoicesFromStorage() {
try { state.invoices = JSON.parse(localStorage.getItem('ouroboros_invoices_'+FISCAL_YEAR)||'[]'); } catch { state.invoices = []; }
}
function saveInvoicesToStorage() {
try { localStorage.setItem('ouroboros_invoices_'+FISCAL_YEAR, JSON.stringify(state.invoices)); } catch {}
}
function renderInvoiceList() {
loadInvoicesFromStorage();
const wrap = document.getElementById('invoice-list-wrap');
const table = document.getElementById('invoice-table');
const empty = document.getElementById('invoice-empty-msg');
const tbody = document.getElementById('invoice-tbody');
// Summary KPIs
const total = state.invoices.reduce((s,i) => s+i.totalInclVat, 0);
const paid = state.invoices.filter(i=>i.status==='paid').reduce((s,i)=>s+i.totalInclVat,0);
const overdue = state.invoices.filter(i=>i.status==='overdue').reduce((s,i)=>s+i.totalInclVat,0);
document.getElementById('invoice-summary').innerHTML = `
<div class="vat-card"><div class="vat-ruta">Totalt fakturerat</div><div class="vat-amount">${formatAmount(total)} kr</div></div>
<div class="vat-card"><div class="vat-ruta">Betalt</div><div class="vat-amount" style="color:var(--green)">${formatAmount(paid)} kr</div></div>
<div class="vat-card"><div class="vat-ruta">Förfallet</div><div class="vat-amount" style="color:var(--red)">${formatAmount(overdue)} kr</div></div>`;
if (!state.invoices.length) {
empty.style.display = ''; table.style.display = 'none';
empty.innerHTML = '<span style="font-size:2rem;display:block;margin-bottom:8px">🧾</span>Inga fakturor ännu. Klicka "Ny faktura" för att börja.';
return;
}
empty.style.display = 'none'; table.style.display = '';
// Check overdue
const today = todayStr();
state.invoices.forEach(inv => { if (inv.status === 'sent' && inv.dueDate < today) inv.status = 'overdue'; });
tbody.innerHTML = state.invoices.slice().reverse().map(inv => {
const statusMap = { draft:'draft', sent:'sent', paid:'paid', overdue:'overdue' };
const cls = statusMap[inv.status]||'draft';
const labels = { draft:'Utkast', sent:'Skickad', paid:'Betald', overdue:'Förfallen' };
return `<tr>
<td><strong>${inv.number}</strong></td>
<td>${escHtml(inv.customer)}</td>
<td>${inv.date}</td>
<td>${inv.dueDate}</td>
<td style="text-align:right">${formatAmount(inv.amount)} kr</td>
<td style="text-align:right">${formatAmount(inv.vatAmount)} kr</td>
<td style="text-align:right"><strong>${formatAmount(inv.totalInclVat)} kr</strong></td>
<td><span class="invoice-status ${cls}">● ${labels[cls]}</span></td>
</tr>`;
}).join('');
}
function openModal(id) { document.getElementById(id).classList.remove('hidden'); }
function closeModal(id) { document.getElementById(id).classList.add('hidden'); }
function openNewInvoice() {
document.getElementById('inv-date').value = todayStr();
const due = new Date(); due.setDate(due.getDate()+30);
document.getElementById('inv-due').value = due.toISOString().split('T')[0];
document.getElementById('inv-customer').value = '';
document.getElementById('inv-amount').value = '';
document.getElementById('inv-ref').value = '';
document.getElementById('inv-desc').value = '';
document.getElementById('inv-feedback').className = '';
document.getElementById('inv-preview').style.display = 'none';
document.getElementById('inv-vat-rate').value = '25';
openModal('modal-invoice');
document.getElementById('inv-amount').addEventListener('input', updateInvPreview);
document.getElementById('inv-vat-rate').addEventListener('change', updateInvPreview);
}
function updateInvPreview() {
const amt = parseFloat(document.getElementById('inv-amount').value)||0;
const rate = parseInt(document.getElementById('inv-vat-rate').value)||25;
const vat = amt * rate / 100;
const total = amt + vat;
const prev = document.getElementById('inv-preview');
if (amt > 0) {
prev.style.display = '';
prev.innerHTML = `<strong>Förhandsgranskning:</strong><br>Belopp exkl. moms: ${formatAmount(amt)} kr | Moms ${rate}%: ${formatAmount(vat)} kr | <strong>Totalt: ${formatAmount(total)} kr</strong>`;
} else { prev.style.display = 'none'; }
}
async function saveInvoice() {
loadInvoicesFromStorage();
const customer = document.getElementById('inv-customer').value.trim();
const date = document.getElementById('inv-date').value;
const dueDate = document.getElementById('inv-due').value;
const amount = parseFloat(document.getElementById('inv-amount').value)||0;
const vatRate = parseInt(document.getElementById('inv-vat-rate').value)||25;
const ref = document.getElementById('inv-ref').value.trim();
const desc = document.getElementById('inv-desc').value.trim();
const feedback = document.getElementById('inv-feedback');
if (!customer) { feedback.textContent = '⚠️ Ange kundnamn'; feedback.className = 'show error'; return; }
if (amount <= 0) { feedback.textContent = '⚠️ Ange belopp > 0'; feedback.className = 'show error'; return; }
if (!date || !dueDate) { feedback.textContent = '⚠️ Ange datum'; feedback.className = 'show error'; return; }
const vatAmount = parseFloat((amount * vatRate / 100).toFixed(2));
const totalInclVat = amount + vatAmount;
const invNumber = `FAK-${FISCAL_YEAR}-${String(state.invoices.length+1).padStart(3,'0')}`;
// Determine VAT account based on rate
const vatAccount = vatRate === 25 ? '2611' : vatRate === 12 ? '2615' : vatRate === 6 ? '2616' : null;
const revenueAccount = vatRate === 25 ? '3010' : vatRate === 12 ? '3020' : '3000';
const btn = document.getElementById('inv-save-btn'); btn.disabled = true; btn.textContent = '⏳ Sparar…';
// Build journal entry lines
const lines = [
{account_number:'1510', account_name:'Kundfordringar', debit:totalInclVat, credit:0},
{account_number:revenueAccount, account_name:'Försäljning', debit:0, credit:amount},
];
if (vatAmount > 0 && vatAccount) {
lines.push({account_number:vatAccount, account_name:'Utgående moms', debit:0, credit:vatAmount});
}
const journalBody = {
date, description: `${invNumber} ${desc || customer}`,
reference: invNumber, lines, fiscal_year: FISCAL_YEAR, period: state.period
};
let journalId = null;
try {
if (!state.usingDemo) {
const res = await apiFetch('/api/ledger/journal', 'POST', journalBody);
journalId = res.id || res.entry_id;
if (journalId) { try { await apiFetch(`/api/ledger/journal/${journalId}/post`, 'POST', {}); } catch(e) {} }
} else {
// Demo mode: update local trial balance
journalId = state.journal.length + 1;
state.journal.push({id:journalId, ...journalBody});
lines.forEach(l => {
let acc = state.trialBalance.find(a => a.account_number === l.account_number);
if (!acc) { acc = {account_number:l.account_number, name:l.account_name, debit:0, credit:0, balance:0}; state.trialBalance.push(acc); }
acc.debit=(acc.debit||0)+l.debit; acc.credit=(acc.credit||0)+l.credit; acc.balance=acc.debit-acc.credit;
});
}
const inv = { number:invNumber, customer, date, dueDate, amount, vatRate, vatAmount, totalInclVat, desc, ref, status:'sent', journalId };
state.invoices.push(inv);
saveInvoicesToStorage();
feedback.textContent = `✅ Faktura ${invNumber} skapad och bokförd (D 1510 / K ${revenueAccount}${vatAccount?` + K ${vatAccount}`:''})`;
feedback.className = 'show';
renderAll();
renderInvoiceList();
setTimeout(() => closeModal('modal-invoice'), 1800);
} catch(e) {
feedback.textContent = `❌ Bokföring misslyckades: ${e.message}`; feedback.className = 'show error';
}
btn.disabled = false; btn.textContent = '💾 Spara & bokför';
}
// ═══════════════════════════════════════════════════════════
// VAT MODULE
// ═══════════════════════════════════════════════════════════
function getVatPeriodsForQuarter(q) {
const qMap = { Q1:['01','02','03'], Q2:['04','05','06'], Q3:['07','08','09'], Q4:['10','11','12'] };
return (qMap[q]||[]).map(m => `${FISCAL_YEAR}-${m}`);
}
async function loadVAT() {
const q = document.getElementById('vat-period-select').value;
const periods = getVatPeriodsForQuarter(q);
const body = document.getElementById('vat-body');
body.innerHTML = '<div style="text-align:center;padding:30px;color:var(--muted);">Hämtar momsdata…</div>';
let allAccounts = [];
if (!state.usingDemo) {
try {
// Fetch trial balance for all periods in the quarter (no period filter = YTD)
// We filter client-side on the accounts we need
const tbYTD = await apiFetch(`/api/ledger/trial-balance?fiscal_year=${FISCAL_YEAR}`);
allAccounts = tbYTD.accounts || [];
} catch(e) { allAccounts = state.trialBalance; }
} else { allAccounts = state.trialBalance; }
function getBalance(num) {
const a = allAccounts.find(x => x.account_number === num);
if (!a) return 0;
return Math.abs(parseFloat(a.total_credit || a.credit || 0));
}
function getDebitBalance(num) {
const a = allAccounts.find(x => x.account_number === num);
if (!a) return 0;
return Math.abs(parseFloat(a.total_debit || a.debit || 0));
}
// Swedish VAT return fields
const outMoms25 = getBalance('2611'); // Utgående moms 25%
const outMoms12 = getBalance('2615'); // Utgående moms 12%
const outMoms6 = getBalance('2616'); // Utgående moms 6%
const inMoms = getDebitBalance('2645'); // Ingående moms
const revAcc3000 = getBalance('3000') + getBalance('3010') + getBalance('3020');
const totalOutMoms = outMoms25 + outMoms12 + outMoms6;
const taxable = totalOutMoms > 0 ? (outMoms25*4 + outMoms12/0.12*0.12 + outMoms6/0.06*0.06) : revAcc3000;
const momsAtBetala = totalOutMoms - inMoms;
// Build ruta display
const rutor = [
{ ruta:'05', label:'Momspliktig omsättning (exkl. moms)', amount: taxable || revAcc3000, note:'Konton 3000/3010/3020' },
{ ruta:'10', label:'Utgående moms 25%', amount: outMoms25, note:'Konto 2611' },
{ ruta:'11', label:'Utgående moms 12%', amount: outMoms12, note:'Konto 2615' },
{ ruta:'12', label:'Utgående moms 6%', amount: outMoms6, note:'Konto 2616' },
{ ruta:'21', label:'Inköp tjänster utlandet (omvänd skattskyldighet, underlag)', amount: 0, note:'Konto 4531 (om relevant)' },
{ ruta:'22', label:'Utgående moms omvänd skattskyldighet', amount: 0, note:'Konto 2614' },
{ ruta:'30', label:'Ingående moms avdragsgill', amount: inMoms, note:'Konto 2645' },
{ ruta:'48', label:'Ingående moms omvänd skattskyldighet', amount: 0, note:'Konto 2640' },
];
let html = `<div class="vat-grid">`;
rutor.forEach(r => {
const hasData = r.amount > 0;
html += `<div class="vat-card">
<div class="vat-ruta">Ruta ${r.ruta}</div>
<div class="vat-label">${r.label}</div>
<div class="vat-amount" style="${!hasData?'color:var(--muted)':''}">${formatAmount(r.amount)} kr</div>
<div style="font-size:.7rem;color:var(--muted);margin-top:4px;">${r.note}</div>
</div>`;
});
html += `</div>`;
// Ruta 49 — moms att betala/återfå
const isPayable = momsAtBetala >= 0;
html += `<div class="vat-total">
<div>
<div class="vat-total-label">Ruta 49 — Moms att ${isPayable?'betala':'återfå'}</div>
<div style="font-size:.78rem;opacity:.7;margin-top:2px;">${q} ${FISCAL_YEAR} · Beräknat underlag</div>
</div>
<div class="vat-total-amount ${isPayable?'to-pay':'to-reclaim'}">${isPayable?'+':''}${formatAmount(Math.abs(momsAtBetala))} kr</div>
</div>`;
html += `<div style="font-size:.78rem;color:var(--muted);margin-top:4px;">
⚠️ Detta är ett indikativt underlag baserat på kontobalanser. Verifiera alltid mot bokförd data innan inlämning till Skatteverket.
</div>`;
body.innerHTML = html;
}
function exportVATReport() {
const q = document.getElementById('vat-period-select').value;
const text = [
`MOMSDEKLARATIONSUNDERLAG`,
`LandveX AB · Org.nr: [fyll i]`,
`Period: ${q} ${FISCAL_YEAR}`,
`Genererat: ${new Date().toLocaleString('sv-SE')}`,
``,
`Underlag baserat på AAMOS Ledger (Ouroboros Finance)`,
``,
`OBS: Slutlig verifiering och inlämning via deklarationssystem.`,
`Kontakta KPMG för granskning av detta underlag.`,
``,
`--- KONTAKTA SYSTEMÄGARE FÖR FULLSTÄNDIG CSV-EXPORT ---`,
].join('\n');
const blob = new Blob([text], {type:'text/plain;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `moms-underlag-${q}-${FISCAL_YEAR}.txt`;
a.click(); URL.revokeObjectURL(url);
}
// ═══════════════════════════════════════════════════════════
// PERIOD CLOSE MODULE
// ═══════════════════════════════════════════════════════════
async function loadPeriodClose() {
const period = state.period;
document.getElementById('period-close-label').textContent = formatPeriodLabel(period);
let periodData = null, tbData = null;
try {
if (!state.usingDemo) {
const [pRes, tbRes] = await Promise.all([
apiFetch(`/api/ledger/periods/${period}/status`),
apiFetch(`/api/ledger/trial-balance?fiscal_year=${FISCAL_YEAR}&period=${period}`)
]);
periodData = pRes;
tbData = tbRes.accounts || [];
} else {
tbData = state.trialBalance;
}
} catch(e) { tbData = state.trialBalance; }
const status = periodData?.status || (state.periods.find(p => p.period === period)?.status) || 'open';
updatePeriodCloseUI(status, tbData, period);
}
function updatePeriodCloseUI(status, tbData, period) {
// Badge
const map = { open:'open', review:'review', approved:'approved', closed:'closed' };
const cls = map[status]||'open';
const labels = { open:'Öppen', review:'Under granskning', approved:'Godkänd', closed:'Stängd' };
const badge = document.getElementById('period-close-badge');
badge.className = `period-badge ${cls}`;
badge.innerHTML = `<span>●</span> <span>${labels[cls]||status}</span>`;
document.getElementById('period-close-status-text').textContent = labels[cls]||status;
// Workflow steps
const steps = ['open','review','approved','closed'];
const idx = steps.indexOf(status);
steps.forEach((s,i) => {
const el = document.getElementById('wf-'+s);
if (i < idx) el.className = 'wf-step done';
else if (i === idx) el.className = 'wf-step active';
else el.className = 'wf-step';
});
// Checklist
function getBalance(num) {
const a = tbData.find(x => x.account_number === num);
if (!a) return 0;
return parseFloat(a.balance || 0);
}
function getTotalDebit(tb) { return tb.reduce((s,a) => s+parseFloat(a.total_debit||a.debit||0),0); }
function getTotalCredit(tb) { return tb.reduce((s,a) => s+parseFloat(a.total_credit||a.credit||0),0); }
const totalDebit = getTotalDebit(tbData);
const totalCredit = getTotalCredit(tbData);
const balanceOK = Math.abs(totalDebit - totalCredit) < 1;
const interim = getBalance('1689');
const interimOK = Math.abs(interim) < 1;
const vat2611 = Math.abs(getBalance('2611'));
const vatOK = vat2611 > 0 || tbData.length === 0; // OK if moms is tracked or no data
const draftCount = state.journal.filter(e => e.status === 'draft').length;
const entriesOK = draftCount === 0;
function setCheck(iconId, valId, ok, val) {
document.getElementById(iconId).className = `check-icon ${ok?'ok':'warn'}`;
document.getElementById(iconId).textContent = ok ? '✅' : '⚠️';
document.getElementById(valId).textContent = val;
}
setCheck('chk-balance-icon','chk-balance-val', balanceOK, balanceOK?'✓ Balanserar':`Differens: ${formatAmount(Math.abs(totalDebit-totalCredit))} kr`);
setCheck('chk-interimIcon','chk-interim-val', interimOK, interimOK?'✓ 0 kr':`${formatAmount(interim)} kr kvar`);
setCheck('chk-vat-icon','chk-vat-val', vatOK, vatOK?`${formatAmount(vat2611)} kr`:'Kontrollera');
setCheck('chk-entries-icon','chk-entries-val', entriesOK, entriesOK?'✓ Inga utkast':`${draftCount} utkast`);
const allOK = balanceOK && interimOK && entriesOK;
// Action buttons
const actDiv = document.getElementById('period-actions');
const actionMap = {
open: { next:'submit-review', label:'📤 Skicka för granskning', cls:'btn-amber' },
review: { next:'approve', label:'✅ Godkänn period', cls:'btn-amber' },
approved: { next:'close', label:'🔒 Stäng period', cls:'btn-amber' },
closed: { next:'reopen', label:'🔓 Öppna igen', cls:'btn-outline' },
};
const action = actionMap[status];
actDiv.innerHTML = '';
if (action) {
const disabled = (action.next === 'close' && !allOK) ? 'disabled title="Åtgärda checklistans varningar"' : '';
actDiv.innerHTML = `<button class="${action.cls}" ${disabled} onclick="doPeriodAction('${action.next}','${period}')">${action.label}</button>`;
if (action.next === 'close' && !allOK) {
actDiv.innerHTML += `<span style="font-size:.8rem;color:var(--red);margin-left:10px;">⚠️ Åtgärda checklistans varningar innan stängning</span>`;
}
}
// Snapshot button
if (tbData.length > 0) {
actDiv.innerHTML += `<button class="btn-secondary" onclick="showSnapshot()" style="margin-left:8px">📸 Visa trial balance</button>`;
}
}
function showSnapshot() {
const wrap = document.getElementById('period-snapshot-wrap');
const content = document.getElementById('period-snapshot-content');
wrap.style.display = '';
const tb = state.trialBalance;
if (!tb.length) { content.innerHTML = '<div style="color:var(--muted)">Ingen data</div>'; return; }
let html = '<table class="invoice-table"><thead><tr><th>Konto</th><th>Namn</th><th>Debet</th><th>Kredit</th><th>Balans</th></tr></thead><tbody>';
tb.forEach(a => {
const bal = parseFloat(a.balance||0);
html += `<tr><td>${a.account_number}</td><td>${escHtml(a.name||a.account_name||'')}</td><td style="text-align:right">${formatAmount(parseFloat(a.total_debit||a.debit||0))}</td><td style="text-align:right">${formatAmount(parseFloat(a.total_credit||a.credit||0))}</td><td style="text-align:right;font-weight:600">${formatAmount(bal)}</td></tr>`;
});
html += '</tbody></table>';
content.innerHTML = html;
wrap.scrollIntoView({behavior:'smooth', block:'nearest'});
}
async function doPeriodAction(action, period) {
const btn = document.querySelector('#period-actions button');
if (btn) { btn.disabled = true; btn.textContent = '⏳…'; }
const feedback = document.getElementById('period-close-feedback');
try {
if (state.usingDemo) {
await new Promise(r => setTimeout(r, 500));
const nextStatus = { 'submit-review':'review', 'approve':'approved', 'close':'closed', 'reopen':'open' }[action]||'open';
feedback.innerHTML = `<span style="color:var(--green)">✅ Period ${action === 'close' ? 'stängd' : 'uppdaterad'} (demo)</span>`;
// Update local period state
let p = state.periods.find(x => x.period === period);
if (!p) { p = {period, status:'open'}; state.periods.push(p); }
p.status = nextStatus;
renderPeriodStatus();
loadPeriodClose();
if (action === 'close') showSnapshot();
return;
}
const body = { fiscal_year: FISCAL_YEAR };
const res = await apiFetch(`/api/ledger/periods/${period}/${action}`, 'POST', body);
feedback.innerHTML = `<span style="color:var(--green)">✅ ${res.message || 'Periodstatus uppdaterad'}</span>`;
// Refresh data
await loadData();
loadPeriodClose();
if (action === 'close') showSnapshot();
} catch(e) {
feedback.innerHTML = `<span style="color:var(--red)">❌ Fel: ${e.message}</span>`;
}
if (btn) { btn.disabled = false; }
}
// ═══════════════════════════════════════════════════════════
// QUICK ACTIONS (overview tab)
// ═══════════════════════════════════════════════════════════
function importCSV() { alert('Bankfil-import: (Under utveckling — anslut till API för full support)'); }
function showBalanceSheet() {
const tb = state.trialBalance;
let msg = '─── BALANSRÄKNING ───\n\nTILLGÅNGAR\n';
tb.filter(a => /^1/.test(a.account_number) && Math.abs(parseFloat(a.balance)||0) > 0).forEach(a => { msg += ` ${a.account_number} ${a.name||a.account_name}: ${formatAmount(parseFloat(a.balance))} kr\n`; });
msg += '\nEGET KAPITAL & SKULDER\n';
tb.filter(a => /^2/.test(a.account_number) && Math.abs(parseFloat(a.balance)||0) > 0).forEach(a => { msg += ` ${a.account_number} ${a.name||a.account_name}: ${formatAmount(Math.abs(parseFloat(a.balance)))} kr\n`; });
alert(msg);
}
function exportSIE() {
const url = `${API_BASE}/api/ledger/export/sie4?fiscal_year=${FISCAL_YEAR}`;
if (state.usingDemo) { alert('SIE4-export kräver live-anslutning till API.\n\nURL: ' + url); return; }
const tok = state.token || INTERNAL_TOKEN;
window.open(url + (tok ? `&token=${tok}` : ''), '_blank');
}
function periodAction() { switchTab('period'); }
function viewAllJournal(e) {
e.preventDefault();
window.open('ledger.html' + (state.token ? '?token='+state.token : ''), '_blank');
}
function getAccountType(num) {
if (/^1/.test(num)) return 'asset';
if (/^2/.test(num)) return 'equity';
if (/^3/.test(num)) return 'revenue';
return 'expense';
}
// ═══════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════
function init() {
state.token = getToken();
state.period = getCurrentPeriod();
document.getElementById('entry-date').value = todayStr();
populatePeriodDropdown();
addEntryLine(); addEntryLine();
loadInvoicesFromStorage();
loadData();
setInterval(() => { loadData(); }, 30000);
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>