bae705aa97
- 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
1609 lines
84 KiB
HTML
1609 lines
84 KiB
HTML
<!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 1910–1940</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 3000–3999</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 4000–8999</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 (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="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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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>
|