From 72e65d0ea087ef54c36b9a8ed24aa03e338ae5da Mon Sep 17 00:00:00 2001 From: Ryan Maulana Date: Sun, 7 Dec 2025 02:43:17 +0700 Subject: [PATCH] done --- .env | 25 + README.md | 37 + __pycache__/sibal.cpython-314.pyc | Bin 0 -> 261552 bytes ...rnfvfgee5l.apps.googleusercontent.com.json | 1 + requirements.txt | Bin 0 -> 2100 bytes scripts/migrate_add_userid.py | 47 + sibal.py | 6086 +++++++++++++++++ sibal_data.json | 301 + text | 8 + 9 files changed, 6505 insertions(+) create mode 100644 .env create mode 100644 README.md create mode 100644 __pycache__/sibal.cpython-314.pyc create mode 100644 client_secret_303784734780-6or0srck71bpkqcee4e6cernfvfgee5l.apps.googleusercontent.com.json create mode 100644 requirements.txt create mode 100644 scripts/migrate_add_userid.py create mode 100644 sibal.py create mode 100644 sibal_data.json create mode 100644 text diff --git a/.env b/.env new file mode 100644 index 0000000..3155e20 --- /dev/null +++ b/.env @@ -0,0 +1,25 @@ +# .env.example - contoh pengaturan SMTP untuk Gmail +# Copy this file to .env and fill your real credentials (do NOT commit .env) + +# SMTP settings (Gmail recommended) +# Use an App Password (recommended) for Gmail. See README/instructions. + +# Optional: override 'From' email +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=maulanaryan2004@gmail.com +SMTP_PASS=kfebdpeoaxfabzpm +SMTP_SENDER_NAME=SIBAL + +OTP_DEBUG=false +OTP_EXPIRE_MINUTES=10 + +# optional: SMTP_SENDER=display.name@example.com +# OTP_DEBUG=false # default false; set true to print OTP in console for debug +# SMTP_SENDER=some.name@example.com + +# Other optional env vars used by the app +# SECRET_KEY=... +# GOOGLE_CLIENT_ID=... +# GOOGLE_CLIENT_SECRET=... +# GOOGLE_REDIRECT_URI=http://localhost:8051/auth/callback diff --git a/README.md b/README.md new file mode 100644 index 0000000..7208208 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Projek-SIBAL +Sistem Informasi Berbasis Akuntansi Ikan Bawal + +## Setup singkat & environment variables + +1. Salin file `.env.example` ke `.env` atau set environment variables di sistem Anda. + +2. Environment penting yang harus di-set sebelum menjalankan aplikasi: + + - `GOOGLE_CLIENT_ID` dan `GOOGLE_CLIENT_SECRET` — gunakan Google Cloud Console untuk membuat OAuth client. + - `GOOGLE_REDIRECT_URI` — biasanya `http://localhost:8051/auth/callback` saat development. + - `SECRET_KEY` — random string untuk Flask session. + - `SUPABASE_URL` dan `SUPABASE_KEY` — untuk koneksi Supabase (jika Anda gunakan Supabase). + +3. Jalankan aplikasi (PowerShell): + +```powershell +python -m venv .venv +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -Force +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +# set env (sesi terminal) +$env:GOOGLE_CLIENT_ID = 'PASTE_CLIENT_ID' +$env:GOOGLE_CLIENT_SECRET = 'PASTE_CLIENT_SECRET' +python sibal.py +``` + +## Rotasi / revoke Google client secret + +Jika secret sempat terkomit, segera revoke/rotate client secret di Google Cloud Console: +1. Buka https://console.cloud.google.com/apis/credentials +2. Pilih OAuth 2.0 Client IDs lalu revoke/regen secret. +3. Update environment variable `GOOGLE_CLIENT_SECRET` dengan nilai baru. + +## Migrasi data lama (opsional) + +Jika Anda sudah punya data yang tersimpan di Supabase tanpa `user_id`, gunakan skrip `scripts/migrate_add_userid.py` untuk memberi `user_id` default. Hati-hati: lakukan backup terlebih dahulu. diff --git a/__pycache__/sibal.cpython-314.pyc b/__pycache__/sibal.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f548f757f5ab24b36f8b375dfef1f12ddc35e272 GIT binary patch literal 261552 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 literal 0 HcmV?d00001 diff --git a/scripts/migrate_add_userid.py b/scripts/migrate_add_userid.py new file mode 100644 index 0000000..d45112a --- /dev/null +++ b/scripts/migrate_add_userid.py @@ -0,0 +1,47 @@ +""" +Skrip migrasi: tambahkan `user_id` ke semua record yang belum punya. +Jalankan dengan environment sudah ter-set (SUPABASE_URL & SUPABASE_KEY), +contoh: + +PS> $env:SUPABASE_URL = 'https://...' +PS> $env:SUPABASE_KEY = '...' +PS> .\.venv\Scripts\python.exe scripts\migrate_add_userid.py --target-user-id + +PERINGATAN: Ini akan menulis ke DB. Backup sebelum menjalankan. +""" +import argparse +import os +from supabase import create_client + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--target-user-id', required=True, help='User ID yang akan dipakai sebagai owner untuk record lama') + args = parser.parse_args() + + url = os.getenv('SUPABASE_URL') + key = os.getenv('SUPABASE_KEY') + if not url or not key: + print('SUPABASE_URL and SUPABASE_KEY must be set in environment') + return + + client = create_client(url, key) + tables = ['transaksi_pemasukan', 'transaksi_pengeluaran', 'jurnal_umum', 'jurnal_penyesuaian', 'kartu_persediaan'] + + for table in tables: + print(f'Processing table: {table}') + res = client.table(table).select('*').execute() + rows = res.data if res.data else [] + for row in rows: + if 'user_id' not in row or not row.get('user_id'): + try: + client.table(table).update({'user_id': args.target_user_id}).eq('id', row['id']).execute() + print(f"Updated {table} id={row['id']} -> user_id={args.target_user_id}") + except Exception as e: + print(f"Failed to update {table} id={row.get('id')}: {e}") + + print('Migration complete.') + + +if __name__ == '__main__': + main() diff --git a/sibal.py b/sibal.py new file mode 100644 index 0000000..419bc4b --- /dev/null +++ b/sibal.py @@ -0,0 +1,6086 @@ +from dash import Dash, html, dcc, callback, Input, Output, State +import dash +import pandas as pd +from datetime import datetime, timedelta +import json +import random +from supabase import create_client, Client +from dotenv import load_dotenv +import os +import glob +from pathlib import Path +import os, smtplib, ssl +from email.message import EmailMessage + +BASE_DIR = Path(__file__).resolve().parent +ENV_PATH = BASE_DIR / ".env" + +load_dotenv(dotenv_path=ENV_PATH, override=True) + + + +def _load_google_credentials_from_local_file(): + """Attempt to read local client_secret JSON files often downloaded from Google Console. + + Looks for files named `client_secret*.json` or `client_secret.json` and extracts + client_id and client_secret if present. Returns tuple (client_id, client_secret). + """ + files = glob.glob('client_secret*.json') + glob.glob('client_secret.json') + for path in files: + try: + with open(path, 'r', encoding='utf-8') as f: + payload = json.load(f) + # file may contain 'installed' or 'web' + for key in ('installed', 'web'): + if key in payload: + info = payload[key] + cid = info.get('client_id') + secret = info.get('client_secret') + if cid and secret: + print(f"🔐 Loaded Google credentials from {path} (local file)") + return cid, secret + # fallback try top-level keys + if 'client_id' in payload and 'client_secret' in payload: + return payload['client_id'], payload['client_secret'] + except Exception: + continue + return None, None +from flask import Flask, redirect, url_for, session +from supabase import create_client, Client +import os + +# ===== INISIALISASI URUTAN YANG BENAR ===== + +# 1. Inisialisasi app dulu +app = Dash(__name__, suppress_callback_exceptions=True) +app.title = "SIBAL - Sistem Informasi Bimbingan Akuntansi Lanjutan" + +# 2. Setup server +server = app.server + +# 3. Setup SECRET_KEY +import secrets +SECRET_KEY = os.getenv('SECRET_KEY', 'sibal-secure-' + secrets.token_hex(32)) +server.config['SECRET_KEY'] = SECRET_KEY +# Ensure session cookie settings are explicit for local development +# Use Lax so top-level navigations (OAuth redirects) will include the cookie +server.config['SESSION_COOKIE_SAMESITE'] = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') +# For local development over HTTP, do not force Secure; in production set to True +server.config['SESSION_COOKIE_SECURE'] = False +server.config['SESSION_COOKIE_HTTPONLY'] = True +from datetime import timedelta as _td +server.config['PERMANENT_SESSION_LIFETIME'] = _td(days=7) + +# 4. Setup Supabase client +supabase_url = "https://shltrwcweexbdcuogscs.supabase.co" +supabase_key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNobHRyd2N3ZWV4YmRjdW9nc2NzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQwNjk4MjUsImV4cCI6MjA3OTY0NTgyNX0.tVwzjiqORJ0dAZhW_f0PneVqJyummvmPOi-WFC5Wd1I" +supabase_client = create_client(supabase_url, supabase_key) + +# ===== SIMPLE AUTH SYSTEM ===== +import requests +import secrets +import hashlib +import random + +class SimpleUser: + def __init__(self, user_data=None): + if user_data: + self.id = user_data.get('id', '') + self.email = user_data.get('email', '') + self.name = user_data.get('name', '') + self.username = user_data.get('username', '') + self.is_authenticated = True + else: + self.id = '' + self.email = '' + self.name = '' + self.username = '' + self.is_authenticated = False + + def get_id(self): + return str(self.id) + +# Global current_user +current_user = SimpleUser() + +# Simple user storage +users_db = {} + +# Google OAuth config +# Gunakan environment variables agar tidak menyimpan secrets di repo +GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID') +GOOGLE_CLIENT_SECRET = os.getenv('GOOGLE_CLIENT_SECRET', None) +GOOGLE_REDIRECT_URI = os.getenv('GOOGLE_REDIRECT_URI', 'http://localhost:8051/auth/callback') + +# ===== SIMPLE AUTH FUNCTIONS ===== +def login_user(user, remember=False): + """Gantikan fungsi login_user dari Flask-Login""" + global current_user + current_user = SimpleUser(user) + session['user_id'] = user['id'] + session['user_email'] = user['email'] + # Set active user in data layer so data dipisah per akun + try: + sibal_data.set_active_user(user.get('id')) + except Exception: + pass + # make the session permanent so it survives short redirects + try: + session.permanent = True + except Exception: + pass + print(f"✅ User logged in: {user['email']}") + +def authenticate_user(identifier, password): + """Gantikan auth_system.authenticate_user""" + user = None + # Cari user by email atau username + for user_id, user_data in users_db.items(): + if user_data['email'] == identifier or user_data['username'] == identifier: + user = user_data + break + + if not user: + return None, "User tidak ditemukan" + + # Verify password + password_hash = hashlib.sha256(password.encode()).hexdigest() + if user.get('password_hash') != password_hash: + return None, "Password salah" + + return user, "Login berhasil" + +# Update authentication functions untuk menggunakan tabel users yang baru +def create_user(username, email, password): + """Create user dan simpan ke Supabase""" + try: + # Cek apakah email sudah terdaftar di tabel users + existing_users = sibal_data._get_table_data('users') + existing_email = any(user['email'] == email for user in existing_users) + existing_username = any(user['username'] == username for user in existing_users) + + if existing_email: + return None, "Email sudah terdaftar" + if existing_username: + return None, "Username sudah digunakan" + + # Create new user di Supabase + user_data = { + 'email': email, + 'username': username, + 'name': username, + 'auth_provider': 'local' + } + + saved_user = sibal_data._insert_table_data('users', user_data) + if saved_user: + user_data['id'] = saved_user['id'] + # NOTE: do NOT initialize template data automatically. + # New users should start with EMPTY data per requested behavior. + print(f"ℹ️ New user created with id {user_data['id']} (no template initialization)") + return user_data, "User created successfully" + else: + return None, "Gagal membuat user" + + except Exception as e: + print(f"Error creating user: {e}") + return None, "Error sistem" + +def authenticate_user(identifier, password): + """Authenticate user dari Supabase""" + try: + # Untuk sekarang, kita skip password verification + # karena tabel users tidak ada password_hash + users_data = sibal_data._get_table_data('users') + + user_data = None + for user in users_data: + if user['email'] == identifier or user['username'] == identifier: + user_data = user + break + + if not user_data: + return None, "User tidak ditemukan" + + # Untuk development, bypass password check + # Karena tabel users tidak memiliki password_hash + return user_data, "Login berhasil (development mode)" + + except Exception as e: + print(f"Error authenticating user: {e}") + return None, "Error sistem" + +# Simple user storage - HANYA SIMPAN DI MEMORY +users_db = {} + +# Google OAuth config (didefinisikan sebelumnya menggunakan environment variables) + + +# ========== BEAUTIFUL COLOR PALETTE ========== +COLORS = { + 'primary': '#473573', + 'primary_light': '#5A4A8A', + 'primary_dark': '#3A2B5C', + 'secondary': '#53619E', + 'secondary_light': '#6A77B0', + 'secondary_dark': '#454D80', + 'accent_teal': '#88BAC3', + 'accent_teal_light': '#9DC8D0', + 'accent_mint': '#D6F3EE', + 'accent_mint_light': '#E6F7F4', + 'accent_lavender': '#A78BFA', + 'accent_coral': '#FF6B6B', + 'white': '#FFFFFF', + 'gray_50': '#F8FAFC', + 'gray_100': '#F1F5F9', + 'gray_200': '#E2E8F0', + 'gray_300': '#CBD5E1', + 'gray_400': '#94A3B8', + 'gray_500': '#64748B', + 'gray_600': '#475569', + 'gray_700': '#334155', + 'gray_800': '#1E293B', + 'gray_900': '#0F172A', + 'success': '#10B981', + 'success_light': '#34D399', + 'warning': '#F59E0B', + 'warning_light': '#FBBF24', + 'error': '#EF4444', + 'error_light': '#F87171', + 'info': '#3B82F6', + 'info_light': '#60A5FA' +} + +# ========== SISTEM KODE AKUN ========== +KODE_AKUN = { + 'kas': {'kode': '1-110', 'nama': 'Kas'}, + 'persediaan': {'kode': '1-120', 'nama': 'Persediaan Barang Dagang'}, + 'perlengkapan': {'kode': '1-130', 'nama': 'Perlengkapan'}, + 'peralatan': {'kode': '1-210', 'nama': 'Peralatan'}, + 'tanah': {'kode': '1-220', 'nama': 'Tanah'}, + 'bangunan_gazebo': {'kode': '1-230', 'nama': 'Bangunan'}, + 'akumulasi_penyusutan_bangunan': {'kode': '1-231', 'nama': 'Akumulasi Depresiasi Bangunan'}, + 'kendaraan': {'kode': '1-240', 'nama': 'Kendaraan'}, + 'akumulasi_penyusutan_kendaraan': {'kode': '1-241', 'nama': 'Akumulasi Depresiasi Kendaraan'}, + 'akumulasi_penyusutan_peralatan': {'kode': '1-242', 'nama': 'Akumulasi Depresiasi Peralatan'}, + 'utang': {'kode': '2-110', 'nama': 'Utang Dagang'}, + 'utang_gaji': {'kode': '2-120', 'nama': 'Utang Gaji'}, + 'modal': {'kode': '3-110', 'nama': 'Modal'}, + 'pendapatan': {'kode': '4-110', 'nama': 'Penjualan'}, + 'pendapatan_tiket': {'kode': '4-120', 'nama': 'Pendapatan Tiket'}, + 'hpp': {'kode': '5-110', 'nama': 'Harga Pokok Produksi'}, + 'beban_gaji': {'kode': '6-110', 'nama': 'Beban Gaji'}, + 'beban_listrik': {'kode': '6-115', 'nama': 'Beban Listrik'}, + 'beban_penyusutan_bangunan': {'kode': '6-120', 'nama': 'Beban Depresiasi Bangunan'}, + 'beban_penyusutan_kendaraan': {'kode': '6-121', 'nama': 'Beban Depresiasi Kendaraan'}, + 'beban_penyusutan_peralatan': {'kode': '6-122', 'nama': 'Beban Depresiasi Peralatan'}, + 'beban_lainnya': {'kode': '6-130', 'nama': 'Beban Lainnya'} +} + +def login_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.H1("🔐 Login ke SIBAL", style={ + 'textAlign': 'center', + 'color': COLORS['primary'], + 'marginBottom': '10px' + }), + html.P("Sistem Informasi Bimbingan Akuntansi Lanjutan", + style={'textAlign': 'center', 'color': COLORS['gray_600'], 'marginBottom': '40px'}), + + # TOMBOL GOOGLE + html.Div([ + html.A( + html.Button( + [ + html.Img( + src="https://developers.google.com/identity/images/g-logo.png", + style={ + 'width': '20px', + 'height': '20px', + 'marginRight': '12px', + 'verticalAlign': 'middle' + } + ), + "Login dengan Google" + ], + style={ + 'width': '100%', + 'padding': '15px 20px', + 'fontSize': '16px', + 'fontWeight': '500', + 'backgroundColor': '#4285F4', + 'color': 'white', + 'border': 'none', + 'borderRadius': '8px', + 'cursor': 'pointer', + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center', + 'transition': 'all 0.3s ease' + }, + id='btn-google-login' # ← ID UNTUK CALLBACK + ), + href="/auth/login", + style={'textDecoration': 'none'} + ) + ], style={'marginBottom': '30px'}), + + html.Div([ + html.Hr(style={'margin': '20px 0'}), + html.P("Atau login dengan akun yang sudah dibuat", style={ + 'textAlign': 'center', + 'color': COLORS['gray_500'], + 'marginBottom': '20px' + }) + ]), + + # FORM LOGIN MANUAL + html.Div([ + html.Label("Username atau Email", className="form-label"), + dcc.Input( + id='login-identifier', + type='text', + placeholder='Username atau email Anda...', + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Password", className="form-label"), + dcc.Input( + id='login-password', + type='password', + placeholder='Password Anda...', + className="form-input" + ) + ], className="form-group"), + + # TOMBOL LOGIN MANUAL + html.Button( + "🚀 Login", + id='btn-login', # ← ID UNTUK CALLBACK + className="btn btn-primary", + style={'width': '100%', 'marginBottom': '15px'} + ), + + html.Div(id='login-alert'), + + html.Hr(style={'margin': '30px 0'}), + + html.Div([ + html.P("Belum punya akun? ", style={'display': 'inline'}), + dcc.Link( + "Daftar di sini", + href="/signup", + style={ + 'color': COLORS['primary'], + 'fontWeight': 'bold', + 'textDecoration': 'none' + } + ) + ], style={'textAlign': 'center'}) + + ], style={'padding': '50px'}) + ], style={ + 'maxWidth': '450px', + 'margin': '80px auto', + 'backgroundColor': 'white', + 'borderRadius': '12px', + 'boxShadow': '0 10px 25px rgba(0,0,0,0.1)', + 'border': '1px solid #e0e0e0' + }) + ], style={ + 'backgroundColor': COLORS['gray_50'], + 'minHeight': '100vh', + 'padding': '20px', + 'fontFamily': 'Arial, sans-serif' + }) + +def signup_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.H1("🚀 Daftar SIBAL", style={ + 'textAlign': 'center', + 'color': COLORS['primary'], + 'marginBottom': '10px' + }), + html.P("Bergabung dengan sistem akuntansi kami", + style={'textAlign': 'center', 'color': COLORS['gray_600'], 'marginBottom': '40px'}), + + # TOMBOL GOOGLE SIGNUP + html.Div([ + html.A( + html.Button( + [ + html.Img( + src="https://developers.google.com/identity/images/g-logo.png", + style={ + 'width': '20px', + 'height': '20px', + 'marginRight': '12px', + 'verticalAlign': 'middle' + } + ), + "Daftar dengan Google" + ], + style={ + 'width': '100%', + 'padding': '15px 20px', + 'fontSize': '16px', + 'fontWeight': '500', + 'backgroundColor': '#34A853', + 'color': 'white', + 'border': 'none', + 'borderRadius': '8px', + 'cursor': 'pointer', + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center' + }, + id='btn-google-signup' # ← ID UNTUK CALLBACK + ), + href="/auth/login", + style={'textDecoration': 'none'} + ) + ], style={'marginBottom': '30px'}), + + html.Div([ + html.Hr(style={'margin': '20px 0'}), + html.P("Atau daftar dengan email", style={ + 'textAlign': 'center', + 'color': COLORS['gray_500'], + 'marginBottom': '20px' + }) + ]), + + # FORM SIGNUP MANUAL + html.Div([ + html.Label("Username", className="form-label"), + dcc.Input( + id='signup-username', + type='text', + placeholder='Pilih username...', + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Email", className="form-label"), + dcc.Input( + id='signup-email', + type='email', + placeholder='Email Anda...', + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Password", className="form-label"), + dcc.Input( + id='signup-password', + type='password', + placeholder='Password minimal 6 karakter...', + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Konfirmasi Password", className="form-label"), + dcc.Input( + id='signup-confirm-password', + type='password', + placeholder='Ulangi password...', + className="form-input" + ) + ], className="form-group"), + + # TOMBOL SIGNUP MANUAL + html.Button( + "✅ Daftar dengan Email", + id='btn-signup', # ← ID UNTUK CALLBACK + className="btn btn-success", + style={'width': '100%', 'marginBottom': '15px'} + ), + + html.Div(id='signup-alert'), + + html.Hr(style={'margin': '30px 0'}), + + html.Div([ + html.P("Sudah punya akun? ", style={'display': 'inline'}), + dcc.Link( + "Login di sini", + href="/login", + style={ + 'color': COLORS['primary'], + 'fontWeight': 'bold', + 'textDecoration': 'none' + } + ) + ], style={'textAlign': 'center'}) + + ], style={'padding': '50px'}) + ], style={ + 'maxWidth': '450px', + 'margin': '80px auto', + 'backgroundColor': 'white', + 'borderRadius': '12px', + 'boxShadow': '0 10px 25px rgba(0,0,0,0.1)', + 'border': '1px solid #e0e0e0' + }) + ], style={ + 'backgroundColor': COLORS['gray_50'], + 'minHeight': '100vh', + 'padding': '20px', + 'fontFamily': 'Arial, sans-serif' + }) + +def complete_profile_layout(): + """Halaman untuk buat username dan password setelah Google OAuth""" + oauth_user = session.get('oauth_user') + + if not oauth_user: + return html.Div([ + html.Div([ + html.H3("❌ Session Expired", style={'textAlign': 'center', 'color': COLORS['error']}), + html.P("Silakan login kembali.", style={'textAlign': 'center'}), + dcc.Link( + "Kembali ke Login", + href="/login", + style={'display': 'block', 'textAlign': 'center', 'marginTop': '20px'} + ) + ], style={'padding': '50px'}) + ], style={ + 'maxWidth': '500px', + 'margin': '100px auto', + 'backgroundColor': 'white', + 'borderRadius': '12px', + 'boxShadow': '0 10px 25px rgba(0,0,0,0.1)' + }) + + return html.Div([ + html.Div([ + html.Div([ + html.H1("🎉 Selamat Datang!", style={'textAlign': 'center', 'color': COLORS['primary']}), + html.P(f"Halo {oauth_user['name']}!", + style={'textAlign': 'center', 'color': COLORS['gray_600'], 'fontSize': '1.1rem'}), + html.P("Lengkapi profil Anda untuk mulai menggunakan SIBAL", + style={'textAlign': 'center', 'color': COLORS['gray_500']}), + + html.Hr(style={'margin': '20px 0'}), + + html.Div([ + html.Label("Username", className="form-label"), + dcc.Input( + id='complete-username', + type='text', + placeholder='Pilih username...', + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Password", className="form-label"), + dcc.Input( + id='complete-password', + type='password', + placeholder='Buat password minimal 6 karakter...', + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Konfirmasi Password", className="form-label"), + dcc.Input( + id='complete-confirm-password', + type='password', + placeholder='Ulangi password...', + className="form-input" + ) + ], className="form-group"), + + html.Button( + "✅ Simpan Profil & Masuk", + id='btn-complete-profile', + className="btn btn-primary", + style={'width': '100%', 'marginTop': '20px', 'padding': '15px'} + ), + + html.Div(id='complete-profile-alert', style={'marginTop': '20px'}) + + ], style={'padding': '40px'}) + ], style={ + 'maxWidth': '500px', + 'margin': '50px auto', + 'backgroundColor': 'white', + 'borderRadius': '12px', + 'boxShadow': '0 10px 25px rgba(0,0,0,0.1)', + 'border': '1px solid #e0e0e0' + }) + ], style={ + 'backgroundColor': COLORS['gray_50'], + 'minHeight': '100vh', + 'padding': '20px' + }) + +def protected_layout(): + """Layout yang membutuhkan login""" + if current_user.is_authenticated: + return dashboard_layout() + else: + return login_layout() + +def aset_tetap_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-building") + ], className="card-icon"), + html.H1("Manajemen Aset Tetap", className="card-title") + ], className="card-header"), + + html.Div([ + html.Div([ + html.H3("Input Aset Tetap", className="form-section-title"), + + html.Div([ + html.Label("Jenis Aset", className="form-label"), + dcc.Dropdown( + id='jenis-aset', + options=[ + {'label': 'Tanah', 'value': 'tanah'}, + {'label': 'Bangunan Gazebo', 'value': 'bangunan_gazebo'}, + {'label': 'Kendaraan', 'value': 'kendaraan'}, + {'label': 'Peralatan', 'value': 'peralatan'} + ], + placeholder='Pilih Jenis Aset', + className="form-control" + ) + ], className="form-group"), + + html.Div([ + html.Label("Nilai Perolehan (Rp)", className="form-label"), + dcc.Input( + id='nilai-aset', + type='number', + placeholder='0', + min=0, + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Tanggal Perolehan", className="form-label"), + dcc.DatePickerSingle( + id='tanggal-perolehan', + date=datetime.now().date(), + display_format='YYYY-MM-DD', + className="form-control" + ) + ], className="form-group"), + + html.Div([ + html.Label("Masa Manfaat (Tahun)", className="form-label"), + dcc.Input( + id='masa-manfaat', + type='number', + placeholder='0', + min=1, + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Metode Penyusutan", className="form-label"), + dcc.Dropdown( + id='metode-penyusutan', + options=[ + {'label': 'Garis Lurus', 'value': 'garis_lurus'}, + {'label': 'Saldo Menurun', 'value': 'saldo_menurun'} + ], + value='garis_lurus', + className="form-control" + ) + ], className="form-group"), + + html.Div([ + html.Label("Nilai Residu (Rp) - Optional", className="form-label"), + dcc.Input( + id='nilai-residu', + type='number', + placeholder='0', + min=0, + className="form-input" + ) + ], className="form-group"), + + html.Button( + "💾 Simpan Aset Tetap", + id='btn-simpan-aset', + className="btn btn-primary" + ) + ], className="form-container compact-form"), + + html.Div([ + html.H3("Daftar Aset Tetap", className="form-section-title"), + html.Div(id='daftar-aset-tetap', className="table-container"), + html.Hr(), + html.Button( + "📊 Hitung Penyusutan", + id='btn-hitung-penyusutan', + className="btn btn-success" + ) + ], className="data-container"), + ], className="form-row"), + ], className="glass-card"), + + html.Div([ + html.H3("Kalkulator Penyusutan", className="card-title"), + html.Div(id='kalkulator-penyusutan', className="table-container") + ], className="glass-card"), + ], className="main-container") + +app.index_string = f''' + + + + {{%metas%}} + {{%title%}} + {{%favicon%}} + {{%css%}} + + + + + + {{%app_entry%}} + + + +''' + +class SIBALData: + def __init__(self): + # Setup Supabase client + self.url = "https://shltrwcweexbdcuogscs.supabase.co" + self.key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNobHRyd2N3ZWV4YmRjdW9nc2NzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQwNjk4MjUsImV4cCI6MjA3OTY0NTgyNX0.tVwzjiqORJ0dAZhW_f0PneVqJyummvmPOi-WFC5Wd1I" + + try: + self.client: Client = create_client(self.url, self.key) + print("✅ Supabase client connected successfully") + except Exception as e: + print(f"❌ Supabase client initialization failed: {e}") + self.client = None + + # Inisialisasi struktur data + self.init_data_structures() + + # Load data dari Supabase + self.load_all_data() + + def init_data_structures(self): + """Initialize semua struktur data""" + # Data transaksi (semua pengguna) + self._all_transaksi_pemasukan = [] + self._all_transaksi_pengeluaran = [] + self._all_jurnal_umum = [] + self._all_jurnal_penyesuaian = [] + self._all_kartu_persediaan = [] + # Active user id (digunakan untuk memfilter data per-akun) + self.active_user_id = None + + # Data master (untuk sementara di memory) + self.master_persediaan = [ + {'kode': 'IK001', 'nama': 'Ikan Bawal Segar', 'satuan': 'kg', 'harga_beli': 25000, 'harga_jual': 35000} + ] + self.suppliers = [] + # Per-user storage untuk suppliers dan buku_besar_pembantu + self._all_suppliers = {} + self._all_buku_besar_pembantu = {} + + # Data aset tetap + self.aset_tetap = { + 'tanah': {'nilai_awal': 0, 'penyusutan': 0, 'masa_manfaat': 0, 'tahun_pembelian': datetime.now().year}, + 'bangunan_gazebo': {'nilai_awal': 0, 'penyusutan': 0, 'masa_manfaat': 10, 'tahun_pembelian': datetime.now().year}, + 'kendaraan': {'nilai_awal': 0, 'penyusutan': 0, 'masa_manfaat': 5, 'tahun_pembelian': datetime.now().year}, + 'peralatan': {'nilai_awal': 0, 'penyusutan': 0, 'masa_manfaat': 3, 'tahun_pembelian': datetime.now().year} + } + + # Daftar akun untuk dropdown dengan kode + self.daftar_akun = [ + {'label': f"{KODE_AKUN['kas']['kode']} - {KODE_AKUN['kas']['nama']}", 'value': 'kas'}, + {'label': f"{KODE_AKUN['persediaan']['kode']} - {KODE_AKUN['persediaan']['nama']}", 'value': 'persediaan'}, + {'label': f"{KODE_AKUN['perlengkapan']['kode']} - {KODE_AKUN['perlengkapan']['nama']}", 'value': 'perlengkapan'}, + {'label': f"{KODE_AKUN['utang']['kode']} - {KODE_AKUN['utang']['nama']}", 'value': 'utang'}, + {'label': f"{KODE_AKUN['modal']['kode']} - {KODE_AKUN['modal']['nama']}", 'value': 'modal'}, + {'label': f"{KODE_AKUN['pendapatan']['kode']} - {KODE_AKUN['pendapatan']['nama']}", 'value': 'pendapatan'}, + {'label': f"{KODE_AKUN['pendapatan_tiket']['kode']} - {KODE_AKUN['pendapatan_tiket']['nama']}", 'value': 'pendapatan_tiket'}, + {'label': f"{KODE_AKUN['hpp']['kode']} - {KODE_AKUN['hpp']['nama']}", 'value': 'hpp'}, + {'label': f"{KODE_AKUN['tanah']['kode']} - {KODE_AKUN['tanah']['nama']}", 'value': 'tanah'}, + {'label': f"{KODE_AKUN['bangunan_gazebo']['kode']} - {KODE_AKUN['bangunan_gazebo']['nama']}", 'value': 'bangunan_gazebo'}, + {'label': f"{KODE_AKUN['kendaraan']['kode']} - {KODE_AKUN['kendaraan']['nama']}", 'value': 'kendaraan'}, + {'label': f"{KODE_AKUN['peralatan']['kode']} - {KODE_AKUN['peralatan']['nama']}", 'value': 'peralatan'}, + {'label': f"{KODE_AKUN['akumulasi_penyusutan_bangunan']['kode']} - {KODE_AKUN['akumulasi_penyusutan_bangunan']['nama']}", 'value': 'akumulasi_penyusutan_bangunan'}, + {'label': f"{KODE_AKUN['akumulasi_penyusutan_kendaraan']['kode']} - {KODE_AKUN['akumulasi_penyusutan_kendaraan']['nama']}", 'value': 'akumulasi_penyusutan_kendaraan'}, + {'label': f"{KODE_AKUN['akumulasi_penyusutan_peralatan']['kode']} - {KODE_AKUN['akumulasi_penyusutan_peralatan']['nama']}", 'value': 'akumulasi_penyusutan_peralatan'}, + {'label': f"{KODE_AKUN['beban_gaji']['kode']} - {KODE_AKUN['beban_gaji']['nama']}", 'value': 'beban_gaji'}, + {'label': f"{KODE_AKUN['beban_listrik']['kode']} - {KODE_AKUN['beban_listrik']['nama']}", 'value': 'beban_listrik'}, + {'label': f"{KODE_AKUN['beban_penyusutan_bangunan']['kode']} - {KODE_AKUN['beban_penyusutan_bangunan']['nama']}", 'value': 'beban_penyusutan_bangunan'}, + {'label': f"{KODE_AKUN['beban_penyusutan_kendaraan']['kode']} - {KODE_AKUN['beban_penyusutan_kendaraan']['nama']}", 'value': 'beban_penyusutan_kendaraan'}, + {'label': f"{KODE_AKUN['beban_penyusutan_peralatan']['kode']} - {KODE_AKUN['beban_penyusutan_peralatan']['nama']}", 'value': 'beban_penyusutan_peralatan'}, + {'label': f"{KODE_AKUN['beban_lainnya']['kode']} - {KODE_AKUN['beban_lainnya']['nama']}", 'value': 'beban_lainnya'} + ] + + def _get_table_data(self, table_name): + """Helper function to get all data from a table""" + if not self.client: + print(f"❌ Client not available for {table_name}") + return [] + try: + response = self.client.table(table_name).select("*").execute() + print(f"✅ Loaded {len(response.data)} records from {table_name}") + return response.data + except Exception as e: + print(f"❌ Error getting {table_name}: {e}") + return [] + + def _insert_table_data(self, table_name, data): + """Helper function to insert data into a table""" + if not self.client: + print(f"❌ Client not available for insert to {table_name}") + return None + try: + response = self.client.table(table_name).insert(data).execute() + if response.data: + print(f"✅ Inserted data to {table_name}") + return response.data[0] + else: + print(f"❌ No data returned from insert to {table_name}") + return None + except Exception as e: + print(f"❌ Error inserting to {table_name}: {e}") + return None + + def load_all_data(self): + """Memuat semua data dari Supabase""" + print("🔄 Loading all data from Supabase...") + + try: + # Load data transaksi (simpan di storage global, akan difilter per user saat diakses) + self._all_transaksi_pemasukan = self._get_table_data('transaksi_pemasukan') + self._all_transaksi_pengeluaran = self._get_table_data('transaksi_pengeluaran') + self._all_jurnal_umum = self._get_table_data('jurnal_umum') + self._all_jurnal_penyesuaian = self._get_table_data('jurnal_penyesuaian') + self._all_kartu_persediaan = self._get_table_data('kartu_persediaan') + + # Load aset tetap + assets_data = self._get_table_data('assets') + for asset in assets_data: + if asset['jenis_aset'] in self.aset_tetap: + self.aset_tetap[asset['jenis_aset']] = { + 'nilai_awal': float(asset['nilai_awal']) if asset['nilai_awal'] else 0, + 'penyusutan': float(asset['penyusutan']) if asset['penyusutan'] else 0, + 'masa_manfaat': int(asset['masa_manfaat']) if asset['masa_manfaat'] else 0, + 'tahun_pembelian': int(asset['tahun_pembelian']) if asset['tahun_pembelian'] else datetime.now().year + } + + print("✅ All data loaded successfully from Supabase") + print(f"📊 Statistics: {len(self._all_transaksi_pemasukan)} pemasukan, {len(self._all_transaksi_pengeluaran)} pengeluaran") + print(f"📊 Statistics: {len(self._all_jurnal_umum)} jurnal, {len(self._all_kartu_persediaan)} kartu persediaan") + + except Exception as e: + print(f"❌ Error loading all data from Supabase: {e}") + + # ------------------ User-scoped access helpers ------------------ + def set_active_user(self, user_id): + """Set active user id untuk memfilter data yang ditampilkan/diakses.""" + if user_id: + self.active_user_id = str(user_id) + else: + self.active_user_id = None + + @property + def transaksi_pemasukan(self): + if not self.active_user_id: + return [] + return [t for t in self._all_transaksi_pemasukan if str(t.get('user_id')) == str(self.active_user_id)] + + @property + def transaksi_pengeluaran(self): + if not self.active_user_id: + return [] + return [t for t in self._all_transaksi_pengeluaran if str(t.get('user_id')) == str(self.active_user_id)] + + @property + def jurnal_umum(self): + if not self.active_user_id: + return [] + return [j for j in self._all_jurnal_umum if str(j.get('user_id')) == str(self.active_user_id)] + + @property + def jurnal_penyesuaian(self): + if not self.active_user_id: + return [] + return [j for j in self._all_jurnal_penyesuaian if str(j.get('user_id')) == str(self.active_user_id)] + + @property + def kartu_persediaan(self): + if not self.active_user_id: + return [] + return [k for k in self._all_kartu_persediaan if str(k.get('user_id')) == str(self.active_user_id)] + + # Suppliers dan buku besar pembantu per-user + @property + def suppliers(self): + uid = str(self.active_user_id) if self.active_user_id else None + if not uid: + return [] + if uid not in self._all_suppliers: + self._all_suppliers[uid] = [] + return self._all_suppliers[uid] + + @suppliers.setter + def suppliers(self, value): + uid = str(self.active_user_id) if self.active_user_id else None + if not uid: + return + self._all_suppliers[uid] = value if isinstance(value, list) else list(value) + + @property + def buku_besar_pembantu(self): + uid = str(self.active_user_id) if self.active_user_id else None + if not uid: + return {} + if uid not in self._all_buku_besar_pembantu: + self._all_buku_besar_pembantu[uid] = {} + return self._all_buku_besar_pembantu[uid] + + @buku_besar_pembantu.setter + def buku_besar_pembantu(self, value): + uid = str(self.active_user_id) if self.active_user_id else None + if not uid: + return + self._all_buku_besar_pembantu[uid] = value if isinstance(value, dict) else dict(value) + + + def save_data(self): + """Menyimpan data ke Supabase - untuk backward compatibility""" + self.save_all_data() + + def save_all_data(self): + """Menyimpan semua data ke Supabase""" + try: + print("💾 Saving all data to Supabase...") + + # Simpan transaksi pemasukan (semua storage, tapi sertakan user_id) + for trans in self._all_transaksi_pemasukan: + if 'id' not in trans: + data_to_save = { + 'tanggal': trans['tanggal'], + 'jenis': trans['jenis'], + 'jumlah': float(trans['jumlah']), + 'quantity': int(trans.get('quantity', 0)), + 'hpp': float(trans.get('hpp', 0)), + 'keterangan': trans.get('keterangan', ''), + 'ref': trans.get('ref', ''), + 'kode_akun_debit': trans.get('kode_akun_debit', ''), + 'kode_akun_kredit': trans.get('kode_akun_kredit', ''), + 'user_id': trans.get('user_id', self.active_user_id) + } + saved = self._insert_table_data('transaksi_pemasukan', data_to_save) + if saved: + trans['id'] = saved['id'] + + # Simpan transaksi pengeluaran + for trans in self._all_transaksi_pengeluaran: + if 'id' not in trans: + data_to_save = { + 'tanggal': trans['tanggal'], + 'jenis': trans['jenis'], + 'kode_barang': trans.get('kode_barang', ''), + 'jumlah': float(trans['jumlah']), + 'quantity': int(trans.get('quantity', 0)), + 'metode_bayar': trans.get('metode_bayar', 'tunai'), + 'supplier': trans.get('supplier', ''), + 'keterangan': trans.get('keterangan', ''), + 'ref': trans.get('ref', ''), + 'kode_akun_debit': trans.get('kode_akun_debit', ''), + 'kode_akun_kredit': trans.get('kode_akun_kredit', ''), + 'user_id': trans.get('user_id', self.active_user_id) + } + saved = self._insert_table_data('transaksi_pengeluaran', data_to_save) + if saved: + trans['id'] = saved['id'] + + # Simpan jurnal umum + for jurnal in self._all_jurnal_umum: + if 'id' not in jurnal: + data_to_save = { + 'tanggal': jurnal['tanggal'], + 'keterangan': jurnal['keterangan'], + 'ref': jurnal.get('ref', ''), + 'akun_debit': jurnal['akun_debit'], + 'kode_akun_debit': jurnal['kode_akun_debit'], + 'jumlah_debit': float(jurnal['jumlah_debit']), + 'akun_kredit': jurnal['akun_kredit'], + 'kode_akun_kredit': jurnal['kode_akun_kredit'], + 'jumlah_kredit': float(jurnal['jumlah_kredit']), + 'user_id': jurnal.get('user_id', self.active_user_id) + } + saved = self._insert_table_data('jurnal_umum', data_to_save) + if saved: + jurnal['id'] = saved['id'] + + # Simpan jurnal penyesuaian + for jurnal in self._all_jurnal_penyesuaian: + if 'id' not in jurnal: + data_to_save = { + 'tanggal': jurnal['tanggal'], + 'keterangan': jurnal['keterangan'], + 'ref': jurnal.get('ref', ''), + 'akun_debit': jurnal['akun_debit'], + 'kode_akun_debit': jurnal['kode_akun_debit'], + 'jumlah_debit': float(jurnal['jumlah_debit']), + 'akun_kredit': jurnal['akun_kredit'], + 'kode_akun_kredit': jurnal['kode_akun_kredit'], + 'jumlah_kredit': float(jurnal['jumlah_kredit']), + 'user_id': jurnal.get('user_id', self.active_user_id) + } + saved = self._insert_table_data('jurnal_penyesuaian', data_to_save) + if saved: + jurnal['id'] = saved['id'] + + # Simpan kartu persediaan + for item in self._all_kartu_persediaan: + if 'id' not in item: + data_to_save = { + 'tanggal': item['tanggal'], + 'kode_barang': item['kode_barang'], + 'nama_barang': item['nama_barang'], + 'masuk_qty': int(item['masuk_qty']), + 'masuk_harga': float(item['masuk_harga']), + 'masuk_total': float(item['masuk_total']), + 'keluar_qty': int(item['keluar_qty']), + 'keluar_harga': float(item['keluar_harga']), + 'keluar_total': float(item['keluar_total']), + 'saldo_qty': int(item['saldo_qty']), + 'saldo_harga': float(item['saldo_harga']), + 'saldo_total': float(item['saldo_total']), + 'keterangan': item.get('keterangan', ''), + 'user_id': item.get('user_id', self.active_user_id) + } + saved = self._insert_table_data('kartu_persediaan', data_to_save) + if saved: + item['id'] = saved['id'] + + # Simpan aset tetap + for jenis_aset, data in self.aset_tetap.items(): + if data['nilai_awal'] > 0: # Hanya simpan aset yang sudah diisi + asset_data = { + 'jenis_aset': jenis_aset, + 'nilai_awal': float(data['nilai_awal']), + 'penyusutan': float(data['penyusutan']), + 'masa_manfaat': int(data['masa_manfaat']), + 'tahun_pembelian': int(data['tahun_pembelian']) + } + + # Cek apakah aset sudah ada + existing_assets = self._get_table_data('assets') + existing_asset = next((a for a in existing_assets if a['jenis_aset'] == jenis_aset), None) + + if existing_asset: + # Update existing + try: + self.client.table('assets').update(asset_data).eq('id', existing_asset['id']).execute() + print(f"✅ Updated asset: {jenis_aset}") + except Exception as e: + print(f"❌ Error updating asset {jenis_aset}: {e}") + else: + # Insert new + saved = self._insert_table_data('assets', asset_data) + if saved: + print(f"✅ Inserted new asset: {jenis_aset}") + + print("✅ All data saved successfully to Supabase") + + except Exception as e: + print(f"❌ Error saving all data to Supabase: {e}") + + def initialize_user_from_template(self, user_id, template_path='sibal_data.json'): + """Initialize a new user's data by copying from a local template JSON. + + This will attach `user_id` to each record and save to Supabase and in-memory storage. + """ + try: + if not os.path.exists(template_path): + print(f"⚠️ Template file not found: {template_path}") + return False + + with open(template_path, 'r', encoding='utf-8') as f: + # file may have markdown fences in repo, try to load JSON content + content = f.read() + # strip possible ```json fences + content = content.strip() + if content.startswith('```'): + # remove first and last fences + parts = content.split('\n') + # find first line that starts with ``` and remove it + if parts[0].startswith('```'): + parts = parts[1:] + if parts and parts[-1].startswith('```'): + parts = parts[:-1] + content = '\n'.join(parts) + + data = json.loads(content) + + # Create deterministic random generator per user to make templates unique + uid = str(user_id) + seed = int(hashlib.sha256(uid.encode('utf-8')).hexdigest(), 16) % (2**32) + rng = random.Random(seed) + + # per-user overall scale (makes differences more pronounced) + user_scale = 0.5 + rng.random() * 2.5 # range 0.5 .. 3.0 + + def unique_ref(orig_ref): + if not orig_ref: + orig_ref = 'REF' + return f"{orig_ref}-{uid[:6]}-{rng.randint(100,999)}" + + def jitter_number(value, pct=0.08, apply_scale=True): + try: + v = float(value) + except Exception: + return value + base = v * (user_scale if apply_scale else 1.0) + change = (rng.random() * 2 - 1) * pct + newv = base * (1 + change) + # round sensible for money/ints + if float(value).is_integer(): + return int(round(newv)) + return float(round(newv, 2)) + + # Helper to insert list of records into table and in-memory _all_ list + def insert_list_unique(table_name, records, target_list_name): + if not records: + return + # Shuffle and take a variable sample size so different users may get different subsets + recs = list(records) + rng.shuffle(recs) + sample_ratio = rng.uniform(0.5, 1.2) + sample_size = max(1, int(len(recs) * sample_ratio)) + recs = recs[:sample_size] + + # Optionally add a few synthetic variants per user + extra_copies = rng.randint(0, max(0, int(len(recs) * 0.3))) + for _ in range(extra_copies): + pick = dict(rng.choice(records)) + recs.append(pick) + + for rec in recs: + rec = dict(rec) # copy + # Make some fields unique/per-user + if 'ref' in rec: + rec['ref'] = unique_ref(rec.get('ref')) + # jitter numeric fields more strongly and apply user scale + for num_field in ['jumlah', 'masuk_total', 'masuk_harga', 'keluar_total', 'saldo_total', 'jumlah_debit', 'jumlah_kredit', 'nilai_awal']: + if num_field in rec: + rec[num_field] = jitter_number(rec[num_field], pct=0.12) + # jitter quantity fields + for num_field in ['quantity', 'masuk_qty', 'keluar_qty', 'saldo_qty']: + if num_field in rec: + try: + rec[num_field] = int(jitter_number(rec[num_field], pct=0.25)) + except Exception: + pass + # shift dates + for k in list(rec.keys()): + if 'tanggal' in k and isinstance(rec[k], str): + try: + dt = datetime.fromisoformat(rec[k]) + dt = dt + timedelta(days=rng.randint(-30, 30)) + rec[k] = dt.date().isoformat() + except Exception: + pass + # supplier uniqueness + if 'supplier' in rec and rec.get('supplier'): + rec['supplier'] = f"{rec.get('supplier')}-{uid[-4:]}" + rec['user_id'] = uid + # Try to insert robustly: if DB rejects unknown column, remove it and retry + saved = None + if not self.client: + saved = None + else: + attempts = 0 + data_to_try = dict(rec) + while attempts < 5: + try: + resp = self.client.table(table_name).insert(data_to_try).execute() + if resp and getattr(resp, 'data', None): + saved = resp.data[0] + break + except Exception as e: + msg = str(e) + import re + m = re.search(r"Could not find the '([a-zA-Z0-9_]+)' column", msg) + if m: + col = m.group(1) + if col in data_to_try: + del data_to_try[col] + attempts += 1 + continue + # fallback: stop retrying + break + if saved: + rec['id'] = saved.get('id') + # Always append to in-memory storage so per-user view exists even if DB insert failed + getattr(self, target_list_name).append(rec) + + insert_list_unique('transaksi_pemasukan', data.get('transaksi_pemasukan', []), '_all_transaksi_pemasukan') + insert_list_unique('transaksi_pengeluaran', data.get('transaksi_pengeluaran', []), '_all_transaksi_pengeluaran') + insert_list_unique('jurnal_umum', data.get('jurnal_umum', []), '_all_jurnal_umum') + insert_list_unique('jurnal_penyesuaian', data.get('jurnal_penyesuaian', []), '_all_jurnal_penyesuaian') + insert_list_unique('kartu_persediaan', data.get('kartu_persediaan', []), '_all_kartu_persediaan') + + # suppliers: store per-user list and append uid suffix so names are unique per user + sups = data.get('suppliers', []) + self._all_suppliers[uid] = [f"{s}_{uid[:6]}" for s in sups] + + # buku_besar_pembantu: copy and prefix with user_id (and adapt supplier names) + bbp = data.get('buku_besar_pembantu', {}) + self._all_buku_besar_pembantu[uid] = {} + for sup, items in bbp.items(): + sup_uid = f"{sup}_{uid[:6]}" + self._all_buku_besar_pembantu[uid][sup_uid] = [] + for item in items: + item_copy = dict(item) + item_copy['user_id'] = uid + # jitter numeric fields in helper + for num_field in ['debit', 'kredit', 'saldo']: + if num_field in item_copy: + item_copy[num_field] = jitter_number(item_copy[num_field]) + self._all_buku_besar_pembantu[uid][sup_uid].append(item_copy) + + # aset_tetap: store under user context in assets table, jitter nilai_awal a bit + aset = data.get('aset_tetap', {}) + for jenis, asetdata in aset.items(): + asetdata_copy = dict(asetdata) + asetdata_copy['jenis_aset'] = jenis + # jitter nilai_awal + if 'nilai_awal' in asetdata_copy: + asetdata_copy['nilai_awal'] = jitter_number(asetdata_copy['nilai_awal'], pct=0.15) + asetdata_copy['user_id'] = uid + # insert with same robust approach + if self.client: + attempts = 0 + data_to_try = dict(asetdata_copy) + while attempts < 5: + try: + resp = self.client.table('assets').insert(data_to_try).execute() + break + except Exception as e: + msg = str(e) + import re + m = re.search(r"Could not find the '([a-zA-Z0-9_]+)' column", msg) + if m: + col = m.group(1) + if col in data_to_try: + del data_to_try[col] + attempts += 1 + continue + break + + print(f"✅ Initialized unique template data for user {user_id}") + return True + except Exception as e: + print(f"❌ Error initializing user from template: {e}") + return False + + def kurangi_persediaan(self, kode_barang, qty, tanggal, keterangan): + """Mengurangi persediaan saat penjualan dengan metode FIFO""" + try: + # Cari barang di master + barang = next((item for item in self.master_persediaan if item['kode'] == kode_barang), None) + if not barang: + print(f"Barang dengan kode {kode_barang} tidak ditemukan") + return False + + # Hitung saldo terakhir + saldo_terakhir = 0 + saldo_harga = 0 + if self.kartu_persediaan: + transaksi_barang = [t for t in self.kartu_persediaan if t['kode_barang'] == kode_barang] + if transaksi_barang: + saldo_terakhir = transaksi_barang[-1]['saldo_qty'] + saldo_harga = transaksi_barang[-1]['saldo_harga'] + + if saldo_terakhir < qty: + print(f"Stok tidak cukup. Stok tersedia: {saldo_terakhir}, butuh: {qty}") + return False + + # Hitung dengan metode FIFO + total_keluar = 0 + sisa_qty = qty + transaksi_masuk = [t for t in self.kartu_persediaan + if t['kode_barang'] == kode_barang and t['masuk_qty'] > 0] + + for trans in transaksi_masuk: + if sisa_qty <= 0: + break + + if trans['saldo_qty'] > 0: + qty_keluar = min(sisa_qty, trans['saldo_qty']) + harga_keluar = trans['masuk_harga'] # FIFO: pakai harga pembelian pertama + + total_keluar += qty_keluar * harga_keluar + sisa_qty -= qty_keluar + + # Buat entri kartu persediaan + saldo_qty_baru = saldo_terakhir - qty + saldo_harga_baru = saldo_harga # Harga rata-rata tidak berubah untuk FIFO + + entri_persediaan = { + 'tanggal': tanggal, + 'kode_barang': kode_barang, + 'nama_barang': barang['nama'], + 'masuk_qty': 0, + 'masuk_harga': 0, + 'masuk_total': 0, + 'keluar_qty': qty, + 'keluar_harga': total_keluar / qty if qty > 0 else 0, + 'keluar_total': total_keluar, + 'saldo_qty': saldo_qty_baru, + 'saldo_harga': saldo_harga_baru, + 'saldo_total': saldo_qty_baru * saldo_harga_baru, + 'keterangan': f'Penjualan - {keterangan}' + } + + entri_persediaan['user_id'] = self.active_user_id + self._all_kartu_persediaan.append(entri_persediaan) + return True + + except Exception as e: + print(f"Error mengurangi persediaan: {e}") + return False + + # TAMBAHKAN JUGA FUNGSI INI: + def get_harga_pokok(self, kode_barang): + """Mendapatkan harga pokok dari master persediaan""" + barang = next((item for item in self.master_persediaan if item['kode'] == kode_barang), None) + return barang['harga_beli'] if barang else 0 + +sibal_data = SIBALData() + +def create_top_navigation(): + # Gunakan try-except untuk handle kasus current_user None + try: + is_authenticated = current_user.is_authenticated + except: + is_authenticated = False + + if is_authenticated: + user_section = html.Div([ + html.Div([ + html.Div("👤", className="avatar"), + html.Div([ + html.Div(current_user.username, style={'fontWeight': '600', 'fontSize': '0.95rem'}), + html.Div(current_user.email, style={'fontSize': '0.8rem', 'opacity': '0.7'}) + ]) + ], className="user-menu"), + html.A( + html.Div([ + html.I(className="fas fa-sign-out-alt"), + " Logout" + ], className="nav-link"), + href="/auth/logout", + style={'textDecoration': 'none'} + ) + ], style={'display': 'flex', 'alignItems': 'center', 'gap': '10px'}) + else: + user_section = html.Div([ + dcc.Link([ + html.I(className="fas fa-sign-in-alt"), + " Login" + ], href="/login", className="nav-link"), + dcc.Link([ + html.I(className="fas fa-user-plus"), + " Daftar" + ], href="/signup", className="nav-link") + ], style={'display': 'flex', 'alignItems': 'center', 'gap': '10px'}) + + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.Img( + src="/assets/logo-sibal-png.png", + style={ + 'width': '100%', + 'height': '100%', + 'objectFit': 'contain' + } + ) + ], className="brand-logo"), + html.Div("SIBAL", className="brand-text") + ], className="nav-brand"), + + html.Div([ + dcc.Link([ + html.I(className="fas fa-home"), + " Dashboard" + ], href="/", className="nav-link"), + dcc.Link([ + html.I(className="fas fa-calculator"), + " Akuntansi Dasar" + ], href="/akuntansi-dasar", className="nav-link"), + dcc.Link([ + html.I(className="fas fa-chart-bar"), + " Laporan & Analisis" + ], href="/laporan-analisis", className="nav-link"), + ], className="nav-links"), + + user_section + ], className="nav-container"), + ], className="glass-nav") + +def create_akuntansi_sub_nav(): + return html.Div([ + html.Div([ + dcc.Link([ + html.I(className="fas fa-edit"), + " Transaksi" + ], href="/transaksi", className="sub-nav-link"), + dcc.Link([ + html.I(className="fas fa-box"), + " Kartu Persediaan" + ], href="/kartu-persediaan", className="sub-nav-link"), + dcc.Link([ + html.I(className="fas fa-book"), + " Jurnal Umum" + ], href="/jurnal-umum", className="sub-nav-link"), + dcc.Link([ + html.I(className="fas fa-book-open"), + " Buku Besar" + ], href="/buku-besar", className="sub-nav-link"), + dcc.Link([ + html.I(className="fas fa-users"), + " Buku Besar Pembantu" + ], href="/buku-besar-pembantu", className="sub-nav-link"), + dcc.Link([ + html.I(className="fas fa-building"), + " Aset Tetap" + ], href="/aset-tetap", className="sub-nav-link"), + dcc.Link([ + html.I(className="fas fa-balance-scale"), + " Neraca Saldo" + ], href="/neraca-saldo", className="sub-nav-link"), + ], className="sub-nav-container"), + ], className="sub-nav") + +def create_laporan_sub_nav(): + return html.Div([ + html.Div([ + dcc.Link([ + html.I(className="fas fa-sync-alt"), + " Jurnal Penyesuaian" + ], href="/jurnal-penyesuaian", className="sub-nav-link"), + dcc.Link([ + html.I(className="fas fa-chart-line"), + " Neraca Setelah Penyesuaian" + ], href="/neraca-setelah-penyesuaian", className="sub-nav-link"), + dcc.Link([ + html.I(className="fas fa-money-bill-wave"), + " Laporan Laba Rugi" + ], href="/laporan-laba-rugi", className="sub-nav-link"), + dcc.Link([ + html.I(className="fas fa-file-alt"), + " Laporan Keuangan" + ], href="/laporan-keuangan", className="sub-nav-link"), + ], className="sub-nav-container"), + ], className="sub-nav") + +def dashboard_layout(): + total_pemasukan = sum(t.get('jumlah', 0) for t in sibal_data.transaksi_pemasukan) + total_pengeluaran = sum(t.get('jumlah', 0) for t in sibal_data.transaksi_pengeluaran) + total_jurnal = len(sibal_data.jurnal_umum) + total_persediaan = len(sibal_data.kartu_persediaan) + + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-gem") + ], className="card-icon"), + html.Div([ + html.H1("Dashboard SIBAL", className="card-title"), + html.P("Sistem Informasi Berbasis Akuntansi Ikan Bawal", className="card-subtitle") + ]) + ], className="card-header") + ], className="glass-card"), + + html.Div([ + html.Div([ + html.Div("Rp {:,.0f}".format(total_pemasukan), className="stat-value"), + html.Div("Total Pendapatan", className="stat-label"), + html.I(className="fas fa-arrow-up stat-icon") + ], className="stat-card"), + html.Div([ + html.Div("Rp {:,.0f}".format(total_pengeluaran), className="stat-value"), + html.Div("Total Pengeluaran", className="stat-label"), + html.I(className="fas fa-arrow-down stat-icon") + ], className="stat-card"), + html.Div([ + html.Div("{:d}".format(total_jurnal), className="stat-value"), + html.Div("Jurnal Umum", className="stat-label"), + html.I(className="fas fa-book stat-icon") + ], className="stat-card"), + html.Div([ + html.Div("{:d}".format(total_persediaan), className="stat-value"), + html.Div("Kartu Persediaan", className="stat-label"), + html.I(className="fas fa-box stat-icon") + ], className="stat-card"), + ], className="stats-grid"), + + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-rocket") + ], className="card-icon"), + html.H2("Aksi Cepat", className="card-title") + ], className="card-header"), + + html.Div([ + dcc.Link([ + html.Div([ + html.Div([ + html.I(className="fas fa-calculator") + ], className="action-icon"), + html.H3("Akuntansi Dasar", className="action-title"), + html.P("Kelola transaksi, jurnal, dan buku besar dengan fitur lengkap", className="action-desc") + ]) + ], href="/akuntansi-dasar", className="action-card"), + + dcc.Link([ + html.Div([ + html.Div([ + html.I(className="fas fa-chart-bar") + ], className="action-icon"), + html.H3("Laporan & Analisis", className="action-title"), + html.P("Generate laporan keuangan lengkap dan analisis mendalam", className="action-desc") + ]) + ], href="/laporan-analisis", className="action-card"), + ], className="actions-grid"), + ], className="glass-card"), + ], className="main-container") + +def format_rupiah(amount): + """Format number to Rupiah currency dengan pemisah ribuan""" + if amount == 0: + return "" + return f"Rp{amount:,.0f}".replace(",", ".") + +# ========== LAYOUT FUNCTIONS YANG DIPERBAIKI ========== + +def transaksi_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-edit") + ], className="card-icon"), + html.H1("Transaksi Pemancingan Bawal", className="card-title") + ], className="card-header") + ], className="glass-card"), + + html.Div([ + html.H4("Pilih Tanggal Transaksi:", className="form-label"), + dcc.DatePickerSingle( + id='selected-date', + date=datetime.now().date(), + display_format='YYYY-MM-DD', + className="form-control" + ) + ], className="glass-card"), + + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-arrow-down") + ], className="card-icon"), + html.H2("Pemasukan - Pendapatan Pemancingan", className="card-title") + ], className="card-header"), + + html.Div([ + html.Div([ + html.H3("Input Transaksi Pendapatan", className="form-section-title"), + dcc.Dropdown( + id='pemasukan-jenis', + options=[ + {'label': 'Tiket Masuk', 'value': 'tiket_masuk'}, + {'label': 'Penjualan Ikan Bawal', 'value': 'penjualan_ikan'}, + {'label': 'Setoran Modal', 'value': 'modal'} + ], + placeholder='Pilih Jenis Transaksi', + className="form-control" + ), + # SESUDAH: + html.Div([ + html.Label("Harga Jual per Unit", className="form-label"), + dcc.Input( + id='pemasukan-harga-jual', + type='number', + placeholder='0', + min=0, + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Quantity", className="form-label"), + dcc.Input( + id='pemasukan-qty', + type='number', + placeholder='0', + min=0, + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Quantity (kg) - untuk penjualan ikan", className="form-label"), + dcc.Input( + id='pemasukan-qty', + type='number', + placeholder='0', + min=0, + className="form-input" + ) + ], className="form-group"), + html.Div([ + html.Label("HPP per kg (untuk penjualan ikan)", className="form-label"), + dcc.Input( + id='pemasukan-hpp', + type='number', + placeholder='0', + min=0, + className="form-input" + ) + ], className="form-group"), + html.Div([ + html.Label("Keterangan Transaksi", className="form-label"), + dcc.Input( + id='pemasukan-keterangan', + type='text', + placeholder='Keterangan...', + className="form-input" + ) + ], className="form-group"), + html.Div([ + html.Label("Nomor Referensi", className="form-label"), + dcc.Input( + id='pemasukan-ref', + type='text', + placeholder='REF-...', + className="form-input" + ) + ], className="form-group"), + html.Button( + "➕ Tambah Transaksi", + id='btn-tambah-pemasukan', + className="btn btn-success" + ) + ], className="form-container compact-form"), + + html.Div([ + html.H3("Daftar Transaksi Pendapatan", className="form-section-title"), + html.Div(id='daftar-pemasukan', className="data-container"), + html.Button( + "🗑️ Hapus Transaksi Terpilih", + id='btn-hapus-pemasukan', + className="btn btn-danger" + ), + html.Hr(), + html.Button( + "📊 Akumulasi ke Jurnal", + id='btn-akumulasi-pemasukan', + className="btn btn-primary" + ) + ], className="data-container"), + ], className="form-row"), + ], className="glass-card"), + + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-arrow-up") + ], className="card-icon"), + html.H2("Pengeluaran - Pembelian & Beban", className="card-title") + ], className="card-header"), + + html.Div([ + html.Div([ + html.H3("Input Pengeluaran", className="form-section-title"), + dcc.Dropdown( + id='pengeluaran-jenis', + options=[ + {'label': 'Pembelian Persediaan', 'value': 'pembelian_persediaan'}, + {'label': 'Beban Gaji', 'value': 'beban_gaji'}, + {'label': 'Beban Listrik', 'value': 'beban_listrik'}, + {'label': 'Beban Penyusutan Bangunan', 'value': 'beban_penyusutan_bangunan'}, + {'label': 'Beban Penyusutan Kendaraan', 'value': 'beban_penyusutan_kendaraan'}, + {'label': 'Beban Penyusutan Peralatan', 'value': 'beban_penyusutan_peralatan'}, + {'label': 'Beban Lainnya', 'value': 'beban_lainnya'} + ], + placeholder='Pilih Jenis Pengeluaran', + className="form-control" + ), + dcc.Dropdown( + id='pengeluaran-barang', + options=[{'label': f"{item['kode']} - {item['nama']}", 'value': item['kode']} + for item in sibal_data.master_persediaan], + placeholder='Pilih Barang (hanya untuk pembelian persediaan)', + className="form-control" + ), + + + # SESUDAH: + html.Div([ + html.Label("HPP per Unit", className="form-label"), + dcc.Input( + id='pengeluaran-hpp', + type='number', + placeholder='0', + min=0, + className="form-input" + ) + ], className="form-group"), + + html.Div([ + html.Label("Quantity", className="form-label"), + dcc.Input( + id='pengeluaran-qty', + type='number', + placeholder='0', + min=1, + className="form-input" + ) + ], className="form-group"), + html.Div([ + html.Label("Quantity (kg) - hanya untuk pembelian persediaan", className="form-label"), + dcc.Input( + id='pengeluaran-qty', + type='number', + placeholder='0', + min=1, + className="form-input" + ) + ], className="form-group"), + dcc.Dropdown( + id='pengeluaran-metode', + options=[ + {'label': 'Tunai', 'value': 'tunai'}, + {'label': 'Kredit', 'value': 'kredit'} + ], + placeholder='Metode Pembayaran', + className="form-control" + ), + html.Div([ + html.Label("Nama Supplier (jika kredit)", className="form-label"), + dcc.Input( + id='pengeluaran-supplier', + type='text', + placeholder='Nama supplier...', + className="form-input" + ) + ], className="form-group"), + html.Div([ + html.Label("Keterangan Pengeluaran", className="form-label"), + dcc.Input( + id='pengeluaran-keterangan', + type='text', + placeholder='Keterangan...', + className="form-input" + ) + ], className="form-group"), + html.Div([ + html.Label("Nomor Referensi", className="form-label"), + dcc.Input( + id='pengeluaran-ref', + type='text', + placeholder='REF-...', + className="form-input" + ) + ], className="form-group"), + html.Button( + "➕ Tambah Pengeluaran", + id='btn-tambah-pengeluaran', + className="btn btn-danger" + ) + ], className="form-container compact-form"), + + html.Div([ + html.H3("Daftar Pengeluaran", className="form-section-title"), + html.Div(id='daftar-pengeluaran', className="data-container"), + html.Button( + "🗑️ Hapus Pengeluaran Terpilih", + id='btn-hapus-pengeluaran', + className="btn btn-danger" + ), + html.Hr(), + html.Button( + "📊 Akumulasi ke Jurnal", + id='btn-akumulasi-pengeluaran', + className="btn btn-primary" + ) + ], className="data-container"), + ], className="form-row"), + ], className="glass-card"), + + html.Div([ + html.H3("📈 Summary Akumulasi", className="card-title"), + html.Div(id='summary-akumulasi', className="summary-container") + ], className="glass-card"), + ], className="main-container") + +def jurnal_umum_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-book") + ], className="card-icon"), + html.H1("Jurnal Umum", className="card-title") + ], className="card-header"), + html.Div([ + html.Button( + "🔄 Refresh Data", + id='btn-refresh-jurnal', + className="btn btn-primary" + ), + html.Hr(), + html.H3("Daftar Jurnal Umum", className="form-section-title"), + html.Div(id='tabel-jurnal-umum', className="table-container"), + html.Hr(), + html.H3("Rekapitulasi Jurnal Umum", className="form-section-title"), + html.Div(id='rekapitulasi-jurnal', className="table-container") + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +def kartu_persediaan_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-box") + ], className="card-icon"), + html.H1("Kartu Persediaan Ikan Bawal", className="card-title") + ], className="card-header"), + html.Div([ + html.Button( + "🔄 Refresh Data", + id='btn-refresh-persediaan', + className="btn btn-primary", + style={'marginBottom': '20px'} + ), + html.Div(id='tabel-kartu-persediaan', className="table-container") + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +def buku_besar_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-book-open") + ], className="card-icon"), + html.H1("Buku Besar", className="card-title") + ], className="card-header"), + html.Div([ + html.Div([ + html.H3("Pilih Akun Buku Besar", className="form-section-title"), + dcc.Dropdown( + id='dropdown-akun-buku-besar', + options=sibal_data.daftar_akun, + placeholder='Pilih Akun Buku Besar', + className="form-control" + ) + ], className="form-container compact-form"), + html.Div(id='detail-buku-besar', className="table-container") + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +def buku_besar_pembantu_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-users") + ], className="card-icon"), + html.H1("Buku Besar Pembantu - Utang", className="card-title") + ], className="card-header"), + html.Div([ + # TOMBOL REFRESH dengan callback yang benar + html.Div([ + html.Button( + "🔄 Refresh Data", + id='btn-refresh-buku-pembantu', + className="btn btn-primary", + style={'marginBottom': '20px'} + ) + ], style={'textAlign': 'center'}), + + html.H3("Daftar Supplier", className="form-section-title"), + html.Div(id='daftar-supplier', className="data-container"), + html.Hr(), + html.H3("Detail Utang per Supplier", className="form-section-title"), + html.Div(id='detail-buku-besar-pembantu', className="table-container") + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +def neraca_saldo_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-balance-scale") + ], className="card-icon"), + html.H1("Neraca Saldo", className="card-title") + ], className="card-header"), + html.Div([ + html.Div([ + html.Label("Periode Neraca Saldo", className="form-label"), + dcc.DatePickerSingle( + id='tanggal-neraca-saldo', + date=datetime.now().date(), + display_format='YYYY-MM-DD', + className="form-control" + ) + ], className="form-group"), + html.Button( + "📊 Generate Neraca Saldo", + id='btn-generate-neraca-saldo', + className="btn btn-primary" + ), + html.Div(id='tabel-neraca-saldo', className="table-container") + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +def jurnal_penyesuaian_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-sync-alt") + ], className="card-icon"), + html.H1("Jurnal Penyesuaian", className="card-title") + ], className="card-header"), + html.Div([ + html.Div([ + html.Label("Periode Jurnal Penyesuaian", className="form-label"), + dcc.DatePickerSingle( + id='tanggal-penyesuaian', + date=datetime.now().date(), + display_format='YYYY-MM-DD', + className="form-control" + ) + ], className="form-group"), + html.Button( + "📊 Generate Jurnal Penyesuaian", + id='btn-generate-penyesuaian', + className="btn btn-primary" + ), + html.Div(id='daftar-jurnal-penyesuaian', className="table-container") + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +def neraca_setelah_penyesuaian_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-chart-line") + ], className="card-icon"), + html.H1("Neraca Setelah Penyesuaian", className="card-title") + ], className="card-header"), + html.Div([ + html.Div([ + html.Label("Pilih Tanggal Neraca:", className="form-label"), + dcc.DatePickerSingle( + id='tanggal-neraca-penyesuaian', + date=datetime.now().date(), + display_format='YYYY-MM-DD', + className="form-control" + ) + ], className="form-group"), + html.Button( + "📊 Generate Neraca Setelah Penyesuaian", + id='btn-generate-neraca-penyesuaian', + className="btn btn-primary" + ), + html.Div(id='tabel-neraca-penyesuaian', className="table-container") + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +def laporan_laba_rugi_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-money-bill-wave") + ], className="card-icon"), + html.H1("Laporan Laba Rugi", className="card-title") + ], className="card-header"), + html.Div([ + html.Div([ + html.Label("Periode Laporan Laba Rugi", className="form-label"), + dcc.DatePickerSingle( + id='tanggal-laba-rugi', + date=datetime.now().date(), + display_format='YYYY-MM-DD', + className="form-control" + ) + ], className="form-group"), + html.Button( + "📊 Generate Laporan Laba Rugi", + id='btn-generate-laba-rugi', + className="btn btn-primary" + ), + html.Div(id='tabel-laba-rugi', className="table-container") + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +def neraca_lajur_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-table") + ], className="card-icon"), + html.H1("Neraca Lajur", className="card-title"), + html.P("Worksheet - Laporan Keuangan Komprehensif", className="card-subtitle") + ], className="card-header"), + + html.Div([ + html.Div([ + html.Label("Periode Neraca Lajur:", className="form-label"), + dcc.DatePickerRange( + id='periode-neraca-lajur', + start_date=datetime.now().date().replace(day=1), # awal bulan + end_date=datetime.now().date(), + display_format='YYYY-MM-DD', + className="form-control" + ) + ], className="form-group", style={'marginBottom': '20px'}), + + html.Button( + "📊 Generate Neraca Lajur", + id='btn-generate-neraca-lajur', + className="btn btn-primary", + style={'marginBottom': '20px'} + ), + + html.Div(id='tabel-neraca-lajur', className="table-container") + + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +def neraca_lajur_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-table") + ], className="card-icon"), + html.H1("Neraca Lajur", className="card-title"), + html.P("Worksheet - Laporan Keuangan Komprehensif", className="card-subtitle") + ], className="card-header"), + + html.Div([ + html.Div([ + html.Label("Periode Neraca Lajur:", className="form-label"), + dcc.DatePickerRange( + id='periode-neraca-lajur', + start_date=datetime.now().date().replace(day=1), # awal bulan + end_date=datetime.now().date(), + display_format='YYYY-MM-DD', + className="form-control" + ) + ], className="form-group", style={'marginBottom': '20px'}), + + html.Button( + "📊 Generate Neraca Lajur", + id='btn-generate-neraca-lajur', + className="btn btn-primary", + style={'marginBottom': '20px'} + ), + + html.Div(id='tabel-neraca-lajur', className="table-container") + + ], className="card-content") + ], className="glass-card") + ], className="main-container") + + +def laporan_keuangan_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-file-alt") + ], className="card-icon"), + html.H1("Laporan Keuangan Lengkap", className="card-title") + ], className="card-header"), + html.Div([ + html.Div([ + html.Label("Pilih Tanggal Laporan:", className="form-label"), + dcc.DatePickerSingle( + id='tanggal-laporan-keuangan', + date=datetime.now().date(), + display_format='YYYY-MM-DD', + className="form-control" + ) + ], className="form-group"), + html.Button( + "📊 Generate Laporan Keuangan Lengkap", + id='btn-generate-laporan-keuangan', + className="btn btn-primary" + ), + html.Div(id='tabel-laporan-keuangan', className="table-container") + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +# ==================== CALLBACKS YANG DIPERBAIKI ==================== +# Callback untuk complete profile +@app.callback( + [Output('complete-profile-alert', 'children'), + Output('complete-username', 'value'), + Output('complete-password', 'value'), + Output('complete-confirm-password', 'value')], + Input('btn-complete-profile', 'n_clicks'), + [State('complete-username', 'value'), + State('complete-password', 'value'), + State('complete-confirm-password', 'value')], + prevent_initial_call=True +) +def handle_complete_profile(n_clicks, username, password, confirm_password): + if n_clicks and n_clicks > 0: + oauth_user = session.get('oauth_user') + + if not oauth_user: + return create_alert("Session expired. Silakan login kembali.", "error"), "", "", "" + + if not all([username, password, confirm_password]): + return create_alert("Harap lengkapi semua field.", "error"), dash.no_update, "", "" + + if password != confirm_password: + return create_alert("Password dan konfirmasi password tidak cocok.", "error"), dash.no_update, "", "" + + if len(password) < 6: + return create_alert("Password minimal 6 karakter.", "error"), dash.no_update, "", "" + + # Create user dengan fungsi yang sudah diperbaiki + user, message = create_user(username, oauth_user['email'], password) + + if not user: + return create_alert(message, "error"), dash.no_update, "", "" + + # Login user dengan fungsi yang sudah diperbaiki + login_user(user, remember=True) + + # Clear oauth session + session.pop('oauth_user', None) + + return html.Div([ + html.P("✅ Profil berhasil disimpan! Redirecting...", + style={'color': COLORS['success'], 'textAlign': 'center', 'fontWeight': 'bold'}), + dcc.Location(href='/', id='redirect-home', refresh=True) + ]), "", "", "" + + sibal_data.save_all_data() + + return dash.no_update, dash.no_update, dash.no_update, dash.no_update + + +from flask import request +import json + +from authlib.integrations.flask_client import OAuth + +# Setup OAuth +oauth = OAuth(server) + +# In-memory store for temporarily keeping oauth_user keyed by state. +# This avoids losing the user data when browser cookies are restricted during OAuth redirects. +OAUTH_STATE_STORE = {} + +# Konfigurasi Google OAuth - GANTI dengan credentials Anda +GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID') +GOOGLE_CLIENT_SECRET = os.getenv('GOOGLE_CLIENT_SECRET') +GOOGLE_REDIRECT_URI = os.getenv('GOOGLE_REDIRECT_URI', 'http://localhost:8051/auth/callback') + +# If env vars missing, try local client_secret JSON files (untracked) +if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET: + cid, csec = _load_google_credentials_from_local_file() + if cid and csec: + GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID or cid + GOOGLE_CLIENT_SECRET = GOOGLE_CLIENT_SECRET or csec + # also set in environment so other parts can read it + os.environ.setdefault('GOOGLE_CLIENT_ID', GOOGLE_CLIENT_ID) + os.environ.setdefault('GOOGLE_CLIENT_SECRET', GOOGLE_CLIENT_SECRET) + +# Debug info (do not print secrets) +print(f"🔐 Google Client ID set: {bool(GOOGLE_CLIENT_ID)}") +print(f"🔐 Google Client Secret set: {bool(GOOGLE_CLIENT_SECRET)}") + +try: + google = oauth.register( + name='SIBAL', + client_id=GOOGLE_CLIENT_ID, + client_secret=GOOGLE_CLIENT_SECRET, + server_metadata_url='https://accounts.google.com/.well-known/openid_configuration', + client_kwargs={ + 'scope': 'openid email profile', + 'redirect_uri': 'http://localhost:8051/auth/callback' # PASTIKAN INI SAMA + } + ) + print("✅ Google OAuth configured successfully") +except Exception as e: + print(f"❌ Google OAuth configuration failed: {e}") + google = None + +@server.route('/auth/login') +def auth_login(): + """Redirect ke Google OAuth""" + try: + # Generate state untuk security + state = secrets.token_urlsafe(16) + # Store state in both session and in-memory store as a fallback + session['oauth_state'] = state + # capture action from query param so callback can behave accordingly + try: + action = request.args.get('action') + except Exception: + action = None + OAUTH_STATE_STORE[state] = {'action': action} + try: + # Keep session persistent for the OAuth flow + session.permanent = True + except Exception: + pass + + # Build Google OAuth URL + base_url = "https://accounts.google.com/o/oauth2/v2/auth" + params = { + 'client_id': GOOGLE_CLIENT_ID, + 'redirect_uri': GOOGLE_REDIRECT_URI, + 'response_type': 'code', + 'scope': 'openid email profile', + 'access_type': 'offline', + 'prompt': 'select_account', + 'state': state + } + + auth_url = f"{base_url}?{'&'.join([f'{k}={v}' for k, v in params.items()])}" + print(f"🔐 Redirecting to Google OAuth") + return redirect(auth_url) + + except Exception as e: + print(f"❌ Auth error: {e}") + return redirect('/login?error=auth_failed') + +@server.route('/auth/callback') +def auth_callback(): + """Handle callback dari Google OAuth - SELALU redirect ke complete profile""" + try: + print("🔵 Processing Google OAuth callback...") + # Dump session and args for debugging session persistence + try: + print("🔎 Session before processing:", dict(session)) + except Exception: + print("🔎 Session not printable or empty") + try: + print("🔎 Callback args:", dict(request.args)) + except Exception: + pass + + # Verify state - allow if the state matches session or is present in the in-memory store + state = request.args.get('state') + sess_state = session.get('oauth_state') + if not state: + print("❌ Missing state parameter") + return redirect('/login?error=invalid_state') + if state != sess_state and state not in OAUTH_STATE_STORE: + print("❌ Invalid state parameter") + return redirect('/login?error=invalid_state') + + # Get authorization code + code = request.args.get('code') + if not code: + print("❌ No authorization code") + return redirect('/login?error=no_code') + + # Token exchange dengan Google + token_url = "https://oauth2.googleapis.com/token" + token_data = { + 'client_id': GOOGLE_CLIENT_ID, + 'client_secret': GOOGLE_CLIENT_SECRET, + 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': GOOGLE_REDIRECT_URI + } + + print("🔄 Exchanging code for token...") + token_response = requests.post(token_url, data=token_data) + token_json = token_response.json() + + if 'error' in token_json: + print(f"❌ Token exchange error: {token_json}") + return redirect('/login?error=token_failed') + + access_token = token_json.get('access_token') + print(f"✅ Token received successfully") + + # Get user info dari Google + userinfo_url = "https://www.googleapis.com/oauth2/v3/userinfo" + headers = {'Authorization': f'Bearer {access_token}'} + userinfo_response = requests.get(userinfo_url, headers=headers) + userinfo = userinfo_response.json() + + print(f"✅ User info received: {userinfo['email']}") + + # SELALU redirect ke complete profile, bahkan jika user sudah ada + oauth_user_obj = { + 'id': userinfo['sub'], + 'email': userinfo['email'], + 'name': userinfo.get('name', userinfo['email'].split('@')[0]), + 'picture': userinfo.get('picture', ''), + 'auth_provider': 'google' + } + + # Store oauth_user in the in-memory fallback store keyed by state + try: + if state in OAUTH_STATE_STORE and isinstance(OAUTH_STATE_STORE[state], dict): + OAUTH_STATE_STORE[state]['oauth_user'] = oauth_user_obj + else: + OAUTH_STATE_STORE[state] = {'action': None, 'oauth_user': oauth_user_obj} + print(f"🆕 Stored oauth_user in OAUTH_STATE_STORE for state {state}") + except Exception as e: + print(f"⚠️ Failed to store oauth_user in OAUTH_STATE_STORE: {e}") + + # Also set in session if possible + try: + session['oauth_user'] = oauth_user_obj + session.permanent = True + except Exception: + pass + + # Decide behavior based on action stored for this state ('signup' or 'login') + try: + stored = OAUTH_STATE_STORE.get(state, {}) if state else {} + action = stored.get('action') if isinstance(stored, dict) else None + + users = sibal_data._get_table_data('users') + existing = next((u for u in users if u.get('email') == userinfo.get('email')), None) + + if action == 'signup': + # create user if not exists, but DO NOT login automatically + if existing: + print(f"🔁 User already exists for signup email: {existing.get('email')}") + else: + uname = (userinfo.get('email') or '').split('@')[0] + user_payload = { + 'email': userinfo.get('email'), + 'username': uname, + 'name': userinfo.get('name', uname), + 'auth_provider': 'google' + } + created = sibal_data._insert_table_data('users', user_payload) + if created: + print(f"✅ Created new user (signup) in DB for {created.get('email')}") + # Per-request: do NOT initialize template data on signup; leave data empty + else: + print("❌ Failed to create user record in DB during signup flow") + + # cleanup and redirect to login page with flag + try: + OAUTH_STATE_STORE.pop(state, None) + except Exception: + pass + try: + session.pop('oauth_user', None) + except Exception: + pass + return redirect('/login?registered=google') + + else: + # Default/login flow: if user exists, login; if not, redirect to complete-profile + if existing: + try: + login_user(existing, remember=True) + try: + OAUTH_STATE_STORE.pop(state, None) + except Exception: + pass + try: + session.pop('oauth_user', None) + except Exception: + pass + print(f"🔐 Logged in user via Google: {existing.get('email')}") + return redirect('/') + except Exception as e: + print(f"❌ Failed to login user after Google callback: {e}") + return redirect('/login?error=login_failed') + else: + # No existing user: require complete profile (username/password) + print("ℹ️ No existing user for this Google account; redirecting to complete-profile to create account") + # ensure oauth_user is in store for recovery + if state and isinstance(OAUTH_STATE_STORE.get(state), dict): + OAUTH_STATE_STORE[state]['oauth_user'] = oauth_user_obj + return redirect(f'/complete-profile?state={state}') + + except Exception as e: + print(f"❌ Error creating/fetching user after OAuth: {e}") + import traceback + traceback.print_exc() + return redirect('/login?error=callback_failed') + + except Exception as e: + print(f"❌ Callback error: {e}") + import traceback + traceback.print_exc() + return redirect('/login?error=callback_failed') + +@server.route('/auth/logout') +def auth_logout(): + """Logout user""" + global current_user + current_user = SimpleUser() + # Clear active user from data layer + try: + sibal_data.set_active_user(None) + except Exception: + pass + session.clear() + return redirect('/login') + +@server.route('/complete-profile') +def complete_profile_page(): + """Route untuk halaman complete profile""" + return redirect('/') # Dash akan handle rendering + +# Halaman verifikasi OTP dan callback +def verify_otp_layout(): + return html.Div([ + html.Div([ + html.H1( + "📧 Verifikasi Email", + style={'textAlign': 'center', 'color': COLORS['primary']} + ), + html.P( + "Masukkan kode verifikasi yang dikirim ke email Anda.", + style={'textAlign': 'center', 'color': COLORS['gray_600']} + ), + html.Div([ + html.Label("Kode OTP", className="form-label"), + dcc.Input( + id='input-otp', + type='text', + placeholder='Masukkan kode 6 digit...', + className='form-input' + ) + ], className='form-group'), + + html.Div([ + html.Button( + '✅ Verifikasi', + id='btn-verify-otp', + className='btn btn-primary', + style={'width': '48%', 'marginRight': '4%'} + ), + html.Button( + '🔁 Kirim Ulang', + id='btn-resend-otp', + className='btn btn-secondary', + style={'width': '48%'} + ), + ], style={'display': 'flex', 'gap': '8px'}), + + html.Div(id='verify-alert', style={'marginTop': '20px'}) + ], style={ + 'padding': '40px', + 'maxWidth': '480px', + 'margin': '80px auto', + 'backgroundColor': 'white', + 'borderRadius': '12px', + 'boxShadow': '0 10px 25px rgba(0,0,0,0.08)' + }) + ], style={ + 'backgroundColor': COLORS['gray_50'], + 'minHeight': '100vh', + 'padding': '20px' + }) + + + +@app.callback( + [Output('verify-alert', 'children'), Output('input-otp', 'value')], + [Input('btn-verify-otp', 'n_clicks'), Input('btn-resend-otp', 'n_clicks')], + [State('input-otp', 'value')], + prevent_initial_call=True +) +def handle_verify_otp(n_verify, n_resend, otp_value): + ctx = dash.callback_context + if not ctx.triggered: + return dash.no_update, dash.no_update + + button_id = ctx.triggered[0]['prop_id'].split('.')[0] + + pending = None + try: + pending = session.get('pending_verification') + except Exception: + pending = None + + if not pending: + return create_alert('Tidak ada permintaan verifikasi yang sedang berjalan.', 'error'), '' + + # Resend OTP + if button_id == 'btn-resend-otp' and n_resend: + otp_code = str(random.randint(100000, 999999)) + expires_at = (datetime.now() + timedelta(minutes=10)).isoformat() + try: + session['pending_verification']['otp'] = otp_code + session['pending_verification']['expires_at'] = expires_at + except Exception as e: + print(f"Failed to update session for resend: {e}") + + sent = send_otp_email(pending.get('email'), otp_code) + if sent: + return create_alert('Kode OTP telah dikirim ulang ke email Anda.', 'success'), '' + else: + return create_alert('(Catatan: SMTP tidak dikonfigurasi - OTP dicetak di konsol).', 'info'), '' + + # Verify OTP + if button_id == 'btn-verify-otp' and n_verify: + if not otp_value: + return create_alert('Masukkan kode OTP.', 'error'), '' + + # Check expiry + try: + expires_at = datetime.fromisoformat(pending.get('expires_at')) + if datetime.now() > expires_at: + return create_alert('Kode OTP kedaluwarsa. Silakan kirim ulang.', 'error'), '' + except Exception: + pass + + if str(pending.get('otp')) == str(otp_value).strip(): + # Try to update DB flag + try: + user_id = pending.get('user_id') + if user_id and supabase_client: + supabase_client.table('users').update({'otp_verified': True}).eq('id', user_id).execute() + except Exception as e: + print(f"Failed to update user verification: {e}") + + try: + session.pop('pending_verification', None) + except Exception: + pass + + return html.Div([html.P('✅ Verifikasi berhasil! Silakan login.', style={'color': COLORS['success']}), dcc.Location(href='/login', id='redirect-after-verify', refresh=True)]), '' + else: + return create_alert('Kode OTP salah. Coba lagi.', 'error'), '' + + return dash.no_update, dash.no_update + +# Layout utama +app.layout = html.Div([ + dcc.Location(id='url', refresh=False), + html.Div(id='top-navigation'), + html.Div(id='sub-navigation'), + html.Div(id='page-content') +]) +@app.callback( + [Output('top-navigation', 'children'), + Output('page-content', 'children'), + Output('sub-navigation', 'children')], + Input('url', 'pathname') +) +def display_page(pathname): + print(f"🌐 Navigating to: {pathname}") + try: + print("🔎 Current session keys:", list(session.keys())) + except Exception: + pass + + # Update top navigation + top_nav = create_top_navigation() + + # Check authentication + is_authenticated = current_user.is_authenticated + + # Handle complete-profile route + if pathname == '/complete-profile': + # Try to recover oauth_user from query state first (fallback store), then session + try: + state = request.args.get('state') + except Exception: + state = None + + if state and state in OAUTH_STATE_STORE: + # move oauth_user into session for the Dash rendering code + try: + session['oauth_user'] = OAUTH_STATE_STORE.pop(state) + session.permanent = True + print(f"🔁 Restored oauth_user from OAUTH_STATE_STORE for state {state}") + except Exception: + pass + + if session.get('oauth_user'): + return top_nav, complete_profile_layout(), None + else: + return top_nav, login_layout(), None + + # Always allow access to login/signup/verification pages + if pathname in ['/login', '/signup', '/verify-otp']: + if pathname == '/login': + return top_nav, login_layout(), None + elif pathname == '/signup': + return top_nav, signup_layout(), None + else: + return top_nav, verify_otp_layout(), None + + # Redirect to login if not authenticated + if not is_authenticated: + print("🔒 User not authenticated, redirecting to login") + return top_nav, login_layout(), None + + # User is authenticated, show requested page + sub_nav = None + + akuntansi_pages = [ + '/akuntansi-dasar', '/transaksi', '/kartu-persediaan', '/jurnal-umum', + '/buku-besar', '/buku-besar-pembantu', '/neraca-saldo', '/aset-tetap' + ] + + laporan_pages = [ + '/laporan-analisis', '/jurnal-penyesuaian', '/neraca-setelah-penyesuaian', + '/laporan-laba-rugi', '/laporan-keuangan' + ] + + if pathname in akuntansi_pages: + sub_nav = create_akuntansi_sub_nav() + elif pathname in laporan_pages: + sub_nav = create_laporan_sub_nav() + + # Tentukan konten berdasarkan pathname + if pathname == '/akuntansi-dasar': + content = html.Div([ + html.H1("📊 Akuntansi Dasar", style={'textAlign': 'center', 'marginBottom': '30px'}), + html.P("Pilih menu dari sub-navigation di atas", style={'textAlign': 'center'}) + ]) + elif pathname == '/laporan-analisis': + content = html.Div([ + html.H1("📈 Laporan & Analisis", style={'textAlign': 'center', 'marginBottom': '30px'}), + html.P("Pilih menu dari sub-navigation di atas", style={'textAlign': 'center'}) + ]) + elif pathname == '/transaksi': + content = transaksi_layout() + elif pathname == '/kartu-persediaan': + content = kartu_persediaan_layout() + elif pathname == '/jurnal-umum': + content = jurnal_umum_layout() + elif pathname == '/buku-besar': + content = buku_besar_layout() + elif pathname == '/buku-besar-pembantu': + content = buku_besar_pembantu_layout() # Pastikan ini yang dipanggil + elif pathname == '/neraca-saldo': + content = neraca_saldo_layout() + elif pathname == '/aset-tetap': + content = aset_tetap_layout() + elif pathname == '/jurnal-penyesuaian': + content = jurnal_penyesuaian_layout() + elif pathname == '/neraca-setelah-penyesuaian': + content = neraca_setelah_penyesuaian_layout() + elif pathname == '/laporan-laba-rugi': + content = laporan_laba_rugi_layout() + elif pathname == '/laporan-keuangan': + content = laporan_keuangan_layout() + else: + content = dashboard_layout() + sub_nav = None + + return top_nav, content, sub_nav + + +# Callback untuk login +@app.callback( + [Output('login-alert', 'children'), + Output('login-identifier', 'value'), + Output('login-password', 'value')], + [Input('btn-login', 'n_clicks'), + Input('btn-google-login', 'n_clicks')], + [State('login-identifier', 'value'), + State('login-password', 'value')], + prevent_initial_call=True +) +def handle_login(n_login, n_google, identifier, password): + ctx = dash.callback_context + if not ctx.triggered: + return dash.no_update, dash.no_update, dash.no_update + + button_id = ctx.triggered[0]['prop_id'].split('.')[0] + + if button_id == 'btn-login' and n_login: + if not identifier or not password: + return create_alert("Harap isi username dan password", "error"), dash.no_update, dash.no_update + + # GUNAKAN FUNGSI authenticate_user YANG SUDAH KITA BUAT + user, message = authenticate_user(identifier, password) + + if user: + # GUNAKAN FUNGSI login_user YANG SUDAH KITA BUAT + login_user(user, remember=True) + return html.Div([ + html.P("✅ Login berhasil! Redirecting...", style={'color': COLORS['success']}), + dcc.Location(href='/', id='redirect-dashboard', refresh=True) + ], style={'textAlign': 'center'}), "", "" + else: + return create_alert(message, "error"), dash.no_update, "" + + elif button_id == 'btn-google-login' and n_google: + # Untuk Google OAuth, kita perlu redirect ke Flask route + return html.Div([ + html.P("Mengarahkan ke Google...", style={'color': COLORS['info']}), + dcc.Location(id='google-redirect', href='/auth/login?action=login', refresh=True) + ]), dash.no_update, dash.no_update + + return dash.no_update, dash.no_update, dash.no_update + +# Callback untuk signup +@app.callback( + [Output('signup-alert', 'children'), + Output('signup-username', 'value'), + Output('signup-email', 'value'), + Output('signup-password', 'value'), + Output('signup-confirm-password', 'value')], + [Input('btn-signup', 'n_clicks'), + Input('btn-google-signup', 'n_clicks')], + [State('signup-username', 'value'), + State('signup-email', 'value'), + State('signup-password', 'value'), + State('signup-confirm-password', 'value')], + prevent_initial_call=True +) +def handle_signup(n_signup, n_google, username, email, password, confirm_password): + ctx = dash.callback_context + if not ctx.triggered: + return dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update + + button_id = ctx.triggered[0]['prop_id'].split('.')[0] + + if button_id == 'btn-signup' and n_signup: + if not all([username, email, password, confirm_password]): + return create_alert("Harap isi semua field", "error"), dash.no_update, dash.no_update, dash.no_update, dash.no_update + + if password != confirm_password: + return create_alert("Password dan konfirmasi password tidak cocok", "error"), dash.no_update, dash.no_update, "", "" + + if len(password) < 6: + return create_alert("Password minimal 6 karakter", "error"), dash.no_update, dash.no_update, "", "" + + # GUNAKAN FUNGSI create_user YANG SUDAH KITA BUAT + user, message = create_user(username, email, password) + + if user: + # Buat OTP dan simpan ke session untuk verifikasi + otp_code = str(random.randint(100000, 999999)) + expires_at = (datetime.now() + timedelta(minutes=10)).isoformat() + try: + session['pending_verification'] = { + 'user_id': user.get('id'), + 'email': user.get('email'), + 'username': user.get('username'), + 'otp': otp_code, + 'expires_at': expires_at + } + session.permanent = True + except Exception as e: + print(f"Failed to save pending verification in session: {e}") + + # Kirim email OTP (fungsi send_otp_email ada di file ini) + sent = send_otp_email(user.get('email'), otp_code) + + # Simpan OTP ke Supabase pada kolom users. Jika kolom tidak ada, ignore. + try: + uid = user.get('id') + if uid and supabase_client: + upd = {} + upd['otp'] = int(otp_code) + upd['otp_expires_at'] = expires_at + upd['otp_verified'] = False + try: + supabase_client.table('users').update(upd).eq('id', uid).execute() + except Exception as e: + # jika kolom tidak ada, coba hanya simpan otp + try: + supabase_client.table('users').update({'otp': otp_code}).eq('id', uid).execute() + except Exception: + print(f"[OTP] Warning: failed to save OTP to users table: {e}") + except Exception as e: + print(f"[OTP] Warning saving OTP to DB: {e}") + + info_msg = "Kode verifikasi telah dikirim ke email Anda. Periksa inbox (atau folder spam)." + if not sent: + info_msg = "(Catatan: SMTP tidak dikonfigurasi. OTP dicetak ke konsol saat pengembangan.)" + + # Redirect ke halaman verifikasi OTP + return html.Div([ + html.P(f"✅ Pendaftaran berhasil! {info_msg}", style={'color': COLORS['success']}), + dcc.Location(href='/verify-otp', id='redirect-verify-otp', refresh=True) + ], style={'textAlign': 'center'}), "", "", "", "" + else: + return create_alert(message, "error"), dash.no_update, dash.no_update, "", "" + + elif button_id == 'btn-google-signup' and n_google: + # Untuk Google OAuth, redirect ke Flask route + return html.Div([ + html.P("Mengarahkan ke Google...", style={'color': COLORS['info']}), + dcc.Location(id='google-signup-redirect', href='/auth/login?action=signup', refresh=True) + ]), dash.no_update, dash.no_update, dash.no_update, dash.no_update + + return dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update + +def create_alert(message, alert_type): + colors = { + 'success': COLORS['success'], + 'error': COLORS['error'], + 'warning': COLORS['warning'], + 'info': COLORS['info'] + } + + return html.Div([ + html.P(message, style={ + 'color': colors.get(alert_type, COLORS['gray_600']), + 'padding': '10px', + 'borderRadius': '5px', + 'backgroundColor': f"{colors.get(alert_type, COLORS['gray_200'])}20", + 'border': f"1px solid {colors.get(alert_type, COLORS['gray_300'])}" + }) + ]) + + +def send_otp_email(to_email: str, otp_code: str) -> bool: + smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com") + smtp_port = int(os.getenv("SMTP_PORT", "587")) + smtp_user = os.getenv("SMTP_USER") + smtp_pass = os.getenv("SMTP_PASS") + sender_name = os.getenv("SMTP_SENDER_NAME", "SIBAL") + + print("[SMTP DEBUG] host:", smtp_host, "port:", smtp_port, "user:", smtp_user) + + if not smtp_user or not smtp_pass: + print("[OTP] SMTP not configured") + return False + + try: + msg = EmailMessage() + msg["Subject"] = "SIBAL - Kode Verifikasi (OTP)" + msg["From"] = f"{sender_name} <{smtp_user}>" + msg["To"] = to_email + msg.set_content( + f"Halo,\n\nKode OTP kamu: {otp_code}\n" + f"Berlaku 10 menit.\n\nSalam,\n{sender_name}" + ) + + context = ssl.create_default_context() + with smtplib.SMTP(smtp_host, smtp_port, timeout=20) as server: + server.ehlo() + server.starttls(context=context) + server.ehlo() + server.login(smtp_user, smtp_pass) + server.send_message(msg) + + print(f"✅ Sent OTP to {to_email}") + return True + + except Exception as e: + print("❌ Failed to send OTP email:", repr(e)) + return False +otp_code = str(random.randint(100000, 999999)) +expires_at = (datetime.now() + timedelta(minutes=10)).isoformat() + +def handle_signup(n_clicks, username, email, password, confirm_password): + if not n_clicks: + return dash.no_update + + # 1) Insert user dulu + insert_res = supabase_client.table("users").insert({ + "email": email, + "username": username, + "name": username, + "password_hash": hash_password(password), + "auth_provider": "local" + }).execute() + + user_id = insert_res.data[0]["id"] + + # 2) Generate OTP + otp_code = str(random.randint(100000, 999999)) + expires_at = (datetime.now() + timedelta(minutes=10)).isoformat() + + # 3) Simpan OTP ke user itu + supabase_client.table("users").update({ + "otp": int(otp_code), + "otp_verified": False, + "otp_expires_at": expires_at + }).eq("id", user_id).execute() + + # 4) Kirim OTP ke Gmail user + sent = send_otp_email(email, otp_code) + + if sent: + session["pending_verification"] = { + "email": email, + "otp": otp_code, + "expires_at": expires_at, + "user_id": user_id + } + return create_alert("OTP sudah dikirim ke email kamu. Cek inbox/spam.", "success") + else: + return create_alert("Gagal kirim OTP. Periksa konfigurasi SMTP.", "error") + + + +@app.callback( + Output('pemasukan-jumlah-display', 'children'), + [Input('pemasukan-harga-jual', 'value'), + Input('pemasukan-qty', 'value')] +) +def hitung_jumlah_pemasukan(harga_jual, qty): + """Menghitung jumlah otomatis dari harga jual × quantity""" + if harga_jual and qty: + jumlah = harga_jual * qty + return html.Div([ + html.Strong(f"Total Jumlah: Rp {jumlah:,.0f}", + style={'color': COLORS['success'], 'fontSize': '1.1rem'}) + ]) + return html.Div("Total Jumlah: Rp 0", style={'color': COLORS['gray_500']}) + +# Tambahkan ini di form pemasukan (setelah input quantity): +html.Div(id='pemasukan-jumlah-display', style={'marginBottom': '15px'}) + +@app.callback( + [Output('pemasukan-harga-jual', 'value'), + Output('pemasukan-qty', 'value'), + Output('pemasukan-hpp', 'value'), + Output('pemasukan-keterangan', 'value'), + Output('pemasukan-ref', 'value')], + Input('btn-tambah-pemasukan', 'n_clicks'), + [State('selected-date', 'date'), + State('pemasukan-jenis', 'value'), + State('pemasukan-harga-jual', 'value'), + State('pemasukan-qty', 'value'), + State('pemasukan-hpp', 'value'), + State('pemasukan-keterangan', 'value'), + State('pemasukan-ref', 'value')], + prevent_initial_call=True +) +def tambah_pemasukan(n_clicks, tanggal, jenis, harga_jual, qty, hpp, keterangan, ref): + if n_clicks and n_clicks > 0 and harga_jual and qty and harga_jual > 0 and qty > 0: + print(f"➕ Adding new pemasukan: {jenis} - {harga_jual} × {qty}") + + # HITUNG JUMLAH OTOMATIS + jumlah = harga_jual * qty + + # Tentukan kode akun + if jenis == 'tiket_masuk': + kode_debit = KODE_AKUN['kas']['kode'] + kode_kredit = KODE_AKUN['pendapatan_tiket']['kode'] + elif jenis == 'penjualan_ikan': + kode_debit = KODE_AKUN['kas']['kode'] + kode_kredit = KODE_AKUN['pendapatan']['kode'] + else: # modal + kode_debit = KODE_AKUN['kas']['kode'] + kode_kredit = KODE_AKUN['modal']['kode'] + + # Buat transaksi baru + transaksi_baru = { + 'tanggal': tanggal, + 'jenis': jenis, + 'jumlah': jumlah, # Pakai jumlah yang dihitung otomatis + 'harga_jual': harga_jual, # Simpan harga jual per unit + 'quantity': qty, + 'hpp': hpp if hpp else 0, + 'keterangan': keterangan if keterangan else f'Pendapatan {jenis}', + 'ref': ref if ref else f"REF-P-{datetime.now().strftime('%Y%m%d%H%M%S')}", + 'tipe': 'pemasukan', + 'kode_akun_debit': kode_debit, + 'kode_akun_kredit': kode_kredit + } + + # Simpan ke memory (scoped ke user) + transaksi_baru['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_transaksi_pemasukan.append(transaksi_baru) + + # ✅ SIMPAN KE DATABASE + try: + sibal_data.save_all_data() + print(f"✅ Pemasukan saved to database: {jenis} - {qty} × {harga_jual} = Rp{jumlah:,}") + except Exception as e: + print(f"❌ Failed to save pemasukan: {e}") + + return '', '', '', '', '' + + return dash.no_update + +def tambah_pengeluaran(n_clicks, tanggal, jenis, kode_barang, hpp, qty, metode, supplier, keterangan, ref): + if n_clicks and n_clicks > 0 and hpp and qty and hpp > 0 and qty > 0 and jenis: + print(f"➖ Adding new pengeluaran: {jenis} - {hpp} × {qty}") + + # HITUNG JUMLAH OTOMATIS + jumlah = hpp * qty + + # Default values + metode = metode if metode else 'tunai' + supplier = supplier if supplier else '' + + # Tentukan kode akun + akun_debit_mapping = { + 'pembelian_persediaan': KODE_AKUN['persediaan']['kode'], + 'beban_gaji': KODE_AKUN['beban_gaji']['kode'], + 'beban_listrik': KODE_AKUN['beban_listrik']['kode'], + 'beban_penyusutan_bangunan': KODE_AKUN['beban_penyusutan_bangunan']['kode'], + 'beban_penyusutan_kendaraan': KODE_AKUN['beban_penyusutan_kendaraan']['kode'], + 'beban_penyusutan_peralatan': KODE_AKUN['beban_penyusutan_peralatan']['kode'], + 'beban_lainnya': KODE_AKUN['beban_lainnya']['kode'] + } + + kode_akun_debit = akun_debit_mapping.get(jenis, KODE_AKUN['beban_lainnya']['kode']) + kode_akun_kredit = KODE_AKUN['kas']['kode'] if metode == 'tunai' else KODE_AKUN['utang']['kode'] + + # ✅ UPDATE PERSEDIAAN JIKA PEMBELIAN PERSEDIAAN + if jenis == 'pembelian_persediaan' and kode_barang: + success = update_persediaan_pembelian( + kode_barang, qty, hpp, tanggal, keterangan, supplier + ) + if success: + print(f"✅ Persediaan updated for pembelian: {kode_barang}") + else: + print(f"❌ Failed to update persediaan for pembelian") + + # ✅ TAMBAHKAN SUPPLIER KE DAFTAR JIKA KREDIT + if metode == 'kredit' and supplier and supplier not in sibal_data.suppliers: + sibal_data.suppliers.append(supplier) + print(f"✅ Supplier added: {supplier}") + + # Buat transaksi baru + transaksi_baru = { + 'tanggal': tanggal, + 'jenis': jenis, + 'kode_barang': kode_barang if jenis == 'pembelian_persediaan' else '', + 'jumlah': jumlah, + 'hpp': hpp, + 'quantity': qty, + 'metode_bayar': metode, + 'supplier': supplier if metode == 'kredit' else '', + 'keterangan': keterangan if keterangan else f'Pengeluaran - {jenis}', + 'ref': ref if ref else f"REF-K-{datetime.now().strftime('%Y%m%d%H%M%S')}", + 'tipe': 'pengeluaran', + 'kode_akun_debit': kode_akun_debit, + 'kode_akun_kredit': kode_akun_kredit + } + + # Simpan ke memory (scoped ke user) + transaksi_baru['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_transaksi_pengeluaran.append(transaksi_baru) + + # ✅ SIMPAN KE DATABASE + try: + sibal_data.save_all_data() + print(f"✅ Pengeluaran saved to database: {jenis} - {qty} × {hpp} = Rp{jumlah:,}") + except Exception as e: + print(f"❌ Failed to save pengeluaran: {e}") + + return '', '', '', '', '' + + return dash.no_update + +@app.callback( + Output('pengeluaran-jumlah-display', 'children'), + [Input('pengeluaran-hpp', 'value'), + Input('pengeluaran-qty', 'value')] +) +def hitung_jumlah_pengeluaran(hpp, qty): + """Menghitung jumlah otomatis dari HPP × quantity""" + if hpp and qty: + jumlah = hpp * qty + return html.Div([ + html.Strong(f"Total Jumlah: Rp {jumlah:,.0f}", + style={'color': COLORS['error'], 'fontSize': '1.1rem'}) + ]) + return html.Div("Total Jumlah: Rp 0", style={'color': COLORS['gray_500']}) + +# Tambahkan ini di form pengeluaran (setelah input quantity): +html.Div(id='pengeluaran-jumlah-display', style={'marginBottom': '15px'}) + +@app.callback( + [Output('pengeluaran-hpp', 'value'), + Output('pengeluaran-qty', 'value'), + Output('pengeluaran-keterangan', 'value'), + Output('pengeluaran-supplier', 'value'), + Output('pengeluaran-ref', 'value')], + Input('btn-tambah-pengeluaran', 'n_clicks'), # PASTIKAN ID INI ADA DI LAYOUT + [State('selected-date', 'date'), + State('pengeluaran-jenis', 'value'), + State('pengeluaran-barang', 'value'), + State('pengeluaran-hpp', 'value'), + State('pengeluaran-qty', 'value'), + State('pengeluaran-metode', 'value'), + State('pengeluaran-supplier', 'value'), + State('pengeluaran-keterangan', 'value'), + State('pengeluaran-ref', 'value')], + prevent_initial_call=True +) +def tambah_pengeluaran(n_clicks, tanggal, jenis, kode_barang, hpp, qty, metode, supplier, keterangan, ref): + if n_clicks and n_clicks > 0 and hpp and qty and hpp > 0 and qty > 0 and jenis: + print(f"➖ Adding new pengeluaran: {jenis} - {hpp} × {qty}") + + # HITUNG JUMLAH OTOMATIS + jumlah = hpp * qty + + # Default values + metode = metode if metode else 'tunai' + + # Tentukan kode akun + akun_debit_mapping = { + 'pembelian_persediaan': {'nama': 'Persediaan Barang Dagang', 'kode': KODE_AKUN['persediaan']['kode']}, + 'beban_gaji': {'nama': 'Beban Gaji', 'kode': KODE_AKUN['beban_gaji']['kode']}, + 'beban_listrik': {'nama': 'Beban Listrik', 'kode': KODE_AKUN['beban_listrik']['kode']}, + 'beban_penyusutan_bangunan': {'nama': 'Beban Depresiasi Bangunan', 'kode': KODE_AKUN['beban_penyusutan_bangunan']['kode']}, + 'beban_penyusutan_kendaraan': {'nama': 'Beban Depresiasi Kendaraan', 'kode': KODE_AKUN['beban_penyusutan_kendaraan']['kode']}, + 'beban_penyusutan_peralatan': {'nama': 'Beban Depresiasi Peralatan', 'kode': KODE_AKUN['beban_penyusutan_peralatan']['kode']}, + 'beban_lainnya': {'nama': 'Beban Lainnya', 'kode': KODE_AKUN['beban_lainnya']['kode']} + } + + kode_akun_debit = akun_debit_mapping.get(jenis, {'nama': 'Beban Lainnya', 'kode': KODE_AKUN['beban_lainnya']['kode']}) + kode_akun_kredit = KODE_AKUN['kas']['kode'] if metode == 'tunai' else KODE_AKUN['utang']['kode'] + + # ✅ UPDATE PERSEDIAAN JIKA PEMBELIAN PERSEDIAAN + if jenis == 'pembelian_persediaan' and kode_barang: + print(f"🔄 Memanggil update_persediaan_pembelian: {kode_barang}, {qty}, {hpp}") + success = update_persediaan_pembelian( + kode_barang, qty, hpp, tanggal, keterangan, supplier + ) + if success: + print(f"✅ Persediaan updated for pembelian: {kode_barang}") + else: + print(f"❌ Failed to update persediaan for pembelian") + else: + print(f"ℹ️ Bukan pembelian persediaan, skip update persediaan") + + # ✅ TAMBAHKAN SUPPLIER KE DAFTAR JIKA KREDIT + if metode == 'kredit' and supplier and supplier not in sibal_data.suppliers: + sibal_data.suppliers.append(supplier) + print(f"✅ Supplier added: {supplier}") + + # Inisialisasi buku besar pembantu untuk supplier baru + if supplier not in sibal_data.buku_besar_pembantu: + sibal_data.buku_besar_pembantu[supplier] = [] + + # Buat transaksi baru + transaksi_baru = { + 'tanggal': tanggal, + 'jenis': jenis, + 'kode_barang': kode_barang if jenis == 'pembelian_persediaan' else '', + 'jumlah': jumlah, + 'hpp': hpp, + 'quantity': qty, + 'metode_bayar': metode, + 'supplier': supplier if metode == 'kredit' else '', + 'keterangan': keterangan if keterangan else f'Pengeluaran - {jenis}', + 'ref': ref if ref else f"REF-K-{datetime.now().strftime('%Y%m%d%H%M%S')}", + 'tipe': 'pengeluaran', + 'kode_akun_debit': kode_akun_debit['kode'], + 'kode_akun_kredit': kode_akun_kredit + } + + # Simpan ke memory (scoped ke user) + transaksi_baru['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_transaksi_pengeluaran.append(transaksi_baru) + + # ✅ TAMBAHKAN KE BUKU BESAR PEMBANTU JIKA KREDIT + if metode == 'kredit' and supplier: + if supplier not in sibal_data.buku_besar_pembantu: + sibal_data.buku_besar_pembantu[supplier] = [] + + # Hitung saldo terakhir + saldo_terakhir = 0 + if sibal_data.buku_besar_pembantu[supplier]: + saldo_terakhir = sibal_data.buku_besar_pembantu[supplier][-1]['saldo'] + + saldo_baru = saldo_terakhir + jumlah + + # Tambahkan transaksi ke buku besar pembantu + sibal_data.buku_besar_pembantu[supplier].append({ + 'tanggal': tanggal, + 'keterangan': f"{jenis} - {keterangan}", + 'debit': 0, + 'kredit': jumlah, + 'saldo': saldo_baru + }) + print(f"✅ Added to buku besar pembantu: {supplier} - Rp {jumlah:,}") + + # ✅ SIMPAN KE DATABASE + try: + sibal_data.save_all_data() + print(f"✅ Pengeluaran saved to database: {jenis} - {qty} × {hpp} = Rp{jumlah:,}") + except Exception as e: + print(f"❌ Failed to save pengeluaran: {e}") + + return '', '', '', '', '' + + return dash.no_update + +@app.callback( + Output('daftar-pemasukan', 'children', allow_duplicate=True), + Input('btn-hapus-pemasukan', 'n_clicks'), + [State('selected-date', 'date'), + State({'type': 'pemasukan-check', 'index': dash.ALL}, 'value')], + prevent_initial_call=True +) +def hapus_pemasukan(n_clicks, tanggal, checklists): + if n_clicks and n_clicks > 0: + transaksi_hari_ini = [t for t in sibal_data.transaksi_pemasukan if t['tanggal'] == tanggal] + indices_to_delete = [] + + for i, checked in enumerate(checklists): + if checked and 'selected' in checked and i < len(transaksi_hari_ini): + indices_to_delete.append(i) + + if indices_to_delete: + # Hapus dari belakang untuk menghindari index error + deleted_count = 0 + for i in sorted(indices_to_delete, reverse=True): + if i < len(transaksi_hari_ini): + transaksi_to_delete = transaksi_hari_ini[i] + sibal_data.transaksi_pemasukan.remove(transaksi_to_delete) + deleted_count += 1 + + sibal_data.save_all_data() + return update_daftar_transaksi(tanggal, None, None, None, None)[0] + + return dash.no_update + +@app.callback( + Output('daftar-pengeluaran', 'children', allow_duplicate=True), + Input('btn-hapus-pengeluaran', 'n_clicks'), + [State('selected-date', 'date'), + State({'type': 'pengeluaran-check', 'index': dash.ALL}, 'value')], + prevent_initial_call=True +) +def hapus_pengeluaran(n_clicks, tanggal, checklists): + if n_clicks and n_clicks > 0: + transaksi_hari_ini = [t for t in sibal_data.transaksi_pengeluaran if t['tanggal'] == tanggal] + indices_to_delete = [] + + for i, checked in enumerate(checklists): + if checked and 'selected' in checked and i < len(transaksi_hari_ini): + indices_to_delete.append(i) + + if indices_to_delete: + # Hapus dari belakang untuk menghindari index error + deleted_count = 0 + for i in sorted(indices_to_delete, reverse=True): + if i < len(transaksi_hari_ini): + transaksi_to_delete = transaksi_hari_ini[i] + sibal_data.transaksi_pengeluaran.remove(transaksi_to_delete) + deleted_count += 1 + + sibal_data.save_all_data() + return update_daftar_transaksi(tanggal, None, None, None, None)[1] + + return dash.no_update + +@app.callback( + [Output('daftar-pemasukan', 'children'), + Output('daftar-pengeluaran', 'children')], + [Input('selected-date', 'date'), + Input('btn-tambah-pemasukan', 'n_clicks'), + Input('btn-tambah-pengeluaran', 'n_clicks'), + Input('btn-hapus-pemasukan', 'n_clicks'), + Input('btn-hapus-pengeluaran', 'n_clicks')] +) +def update_daftar_transaksi(tanggal, n_pemasukan, n_pengeluaran, n_hapus_pemasukan, n_hapus_pengeluaran): + """Update daftar transaksi untuk tanggal terpilih""" + + # Tampilkan transaksi pemasukan untuk tanggal terpilih + transaksi_pemasukan_hari_ini = [t for t in sibal_data.transaksi_pemasukan if t['tanggal'] == tanggal] + + if transaksi_pemasukan_hari_ini: + items_pemasukan = [] + for i, trans in enumerate(transaksi_pemasukan_hari_ini): + harga_jual = trans.get('harga_jual', 0) + quantity = trans.get('quantity', 0) + jumlah = trans.get('jumlah', 0) + + items_pemasukan.append(html.Div([ + dcc.Checklist( + id={'type': 'pemasukan-check', 'index': i}, + options=[{'label': '', 'value': 'selected'}], + value=[], + style={'display': 'inline-block', 'marginRight': '10px'} + ), + html.Strong(f"{trans.get('jenis', 'N/A').replace('_', ' ').title()}"), + html.Br(), + html.Small(f"Qty: {quantity} × Rp {harga_jual:,} = Rp {jumlah:,}"), + html.Br(), + html.Small(f"Ref: {trans.get('ref', '-')} | {trans.get('keterangan', '')}"), + html.Hr(style={'margin': '5px 0'}) + ])) + daftar_pemasukan = items_pemasukan + else: + daftar_pemasukan = html.P("Belum ada transaksi pendapatan untuk tanggal ini", style={'color': '#6c757d'}) + + # Tampilkan transaksi pengeluaran untuk tanggal terpilih + transaksi_pengeluaran_hari_ini = [t for t in sibal_data.transaksi_pengeluaran if t['tanggal'] == tanggal] + + if transaksi_pengeluaran_hari_ini: + items_pengeluaran = [] + for i, trans in enumerate(transaksi_pengeluaran_hari_ini): + hpp = trans.get('hpp', 0) + quantity = trans.get('quantity', 0) + jumlah = trans.get('jumlah', 0) + jenis_label = trans['jenis'].replace('_', ' ').title() + metode = trans.get('metode_bayar', 'tunai') + + items_pengeluaran.append(html.Div([ + dcc.Checklist( + id={'type': 'pengeluaran-check', 'index': i}, + options=[{'label': '', 'value': 'selected'}], + value=[], + style={'display': 'inline-block', 'marginRight': '10px'} + ), + html.Strong(f"{jenis_label}"), + html.Br(), + html.Small(f"Qty: {quantity} × Rp {hpp:,} = Rp {jumlah:,}"), + html.Br(), + html.Small(f"{trans.get('keterangan', 'N/A')} | Metode: {metode.title()}"), + html.Hr(style={'margin': '5px 0'}) + ])) + daftar_pengeluaran = items_pengeluaran + else: + daftar_pengeluaran = html.P("Belum ada transaksi pengeluaran untuk tanggal ini", style={'color': '#6c757d'}) + + return daftar_pemasukan, daftar_pengeluaran + +def update_persediaan_pembelian(kode_barang, qty, harga, tanggal, keterangan, supplier=""): + """Update kartu persediaan saat pembelian - sistem sederhana""" + try: + # Cari barang di master + barang = next((item for item in sibal_data.master_persediaan if item['kode'] == kode_barang), None) + if not barang: + print(f"❌ Barang dengan kode {kode_barang} tidak ditemukan") + return False + + # Hitung saldo terakhir + transaksi_sebelumnya = [t for t in sibal_data.kartu_persediaan if t['kode_barang'] == kode_barang] + + saldo_qty_sebelumnya = 0 + saldo_total_sebelumnya = 0 + + if transaksi_sebelumnya: + last_trans = transaksi_sebelumnya[-1] + saldo_qty_sebelumnya = last_trans['saldo_qty'] + saldo_total_sebelumnya = last_trans['saldo_total'] + + # Hitung saldo baru + saldo_qty_baru = saldo_qty_sebelumnya + qty + saldo_total_baru = saldo_total_sebelumnya + (qty * harga) + saldo_harga_baru = saldo_total_baru / saldo_qty_baru if saldo_qty_baru > 0 else 0 + + print(f"📊 PEMBELIAN: {saldo_qty_sebelumnya} + {qty} = {saldo_qty_baru} kg") + print(f"📊 NILAI: Rp{saldo_total_sebelumnya:,} + Rp{qty * harga:,} = Rp{saldo_total_baru:,}") + + # Buat entri baru + entri_baru = { + 'tanggal': tanggal, + 'kode_barang': kode_barang, + 'nama_barang': barang['nama'], + 'masuk_qty': qty, + 'masuk_harga': harga, + 'masuk_total': qty * harga, + 'keluar_qty': 0, + 'keluar_harga': 0, + 'keluar_total': 0, + 'saldo_qty': saldo_qty_baru, + 'saldo_harga': saldo_harga_baru, + 'saldo_total': saldo_total_baru, + 'keterangan': f'Pembelian - {keterangan}' + (f' - {supplier}' if supplier else '') + } + + entri_baru['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_kartu_persediaan.append(entri_baru) + print(f"✅ BERHASIL: {kode_barang} +{qty}kg, Saldo: {saldo_qty_baru}kg @ Rp{saldo_harga_baru:,.0f}") + return True + + except Exception as e: + print(f"❌ GAGAL: {e}") + return False + +def update_persediaan_penjualan(kode_barang, qty, tanggal, keterangan): + """Update kartu persediaan saat penjualan - sistem sederhana""" + try: + # Cari barang di master + barang = next((item for item in sibal_data.master_persediaan if item['kode'] == kode_barang), None) + if not barang: + print(f"❌ Barang dengan kode {kode_barang} tidak ditemukan") + return False, 0 + + # Hitung saldo terakhir + transaksi_sebelumnya = [t for t in sibal_data.kartu_persediaan if t['kode_barang'] == kode_barang] + + if not transaksi_sebelumnya: + print(f"❌ Tidak ada stok untuk {kode_barang}") + return False, 0 + + last_trans = transaksi_sebelumnya[-1] + saldo_qty_sebelumnya = last_trans['saldo_qty'] + saldo_total_sebelumnya = last_trans['saldo_total'] + saldo_harga_sebelumnya = last_trans['saldo_harga'] + + # Cek stok cukup + if saldo_qty_sebelumnya < qty: + print(f"❌ Stok tidak cukup: {saldo_qty_sebelumnya} < {qty}") + return False, 0 + + # Hitung HPP + hpp_total = qty * saldo_harga_sebelumnya + + # Hitung saldo baru + saldo_qty_baru = saldo_qty_sebelumnya - qty + saldo_total_baru = saldo_total_sebelumnya - hpp_total + saldo_harga_baru = saldo_total_baru / saldo_qty_baru if saldo_qty_baru > 0 else 0 + + print(f"📊 PENJUALAN: {saldo_qty_sebelumnya} - {qty} = {saldo_qty_baru} kg") + print(f"📊 NILAI: Rp{saldo_total_sebelumnya:,} - Rp{hpp_total:,} = Rp{saldo_total_baru:,}") + + # Buat entri baru + entri_baru = { + 'tanggal': tanggal, + 'kode_barang': kode_barang, + 'nama_barang': barang['nama'], + 'masuk_qty': 0, + 'masuk_harga': 0, + 'masuk_total': 0, + 'keluar_qty': qty, + 'keluar_harga': saldo_harga_sebelumnya, + 'keluar_total': hpp_total, + 'saldo_qty': saldo_qty_baru, + 'saldo_harga': saldo_harga_baru, + 'saldo_total': saldo_total_baru, + 'keterangan': f'Penjualan - {keterangan}' + } + + entri_baru['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_kartu_persediaan.append(entri_baru) + print(f"✅ BERHASIL: {kode_barang} -{qty}kg, HPP: Rp{hpp_total:,}, Saldo: {saldo_qty_baru}kg") + return True, hpp_total + + except Exception as e: + print(f"❌ GAGAL: {e}") + return False, 0 + +def hitung_ulang_semua_saldo(): + """Hitung ulang semua saldo dari transaksi pertama - untuk memperbaiki data yang rusak""" + print("🔄 MENGHITUNG ULANG SEMUA SALDO...") + + # Kelompokkan berdasarkan kode barang + transaksi_per_barang = {} + for item in sibal_data.kartu_persediaan: + kode = item['kode_barang'] + if kode not in transaksi_per_barang: + transaksi_per_barang[kode] = [] + transaksi_per_barang[kode].append(item) + + # Urutkan setiap barang berdasarkan tanggal + for kode_barang in transaksi_per_barang: + transaksi_per_barang[kode_barang].sort(key=lambda x: x['tanggal']) + + # Hapus semua data kartu persediaan + sibal_data.kartu_persediaan.clear() + + # Hitung ulang dari transaksi pertama + for kode_barang, transaksi_list in transaksi_per_barang.items(): + saldo_qty = 0 + saldo_total = 0 + + for i, transaksi in enumerate(transaksi_list): + # Simpan data asli masuk/keluar + masuk_qty = transaksi['masuk_qty'] + masuk_harga = transaksi['masuk_harga'] + masuk_total = transaksi['masuk_total'] + keluar_qty = transaksi['keluar_qty'] + keluar_harga = transaksi['keluar_harga'] + keluar_total = transaksi['keluar_total'] + + # Update saldo + saldo_qty += masuk_qty - keluar_qty + saldo_total += masuk_total - keluar_total + saldo_harga = saldo_total / saldo_qty if saldo_qty > 0 else 0 + + # Buat entri baru dengan saldo yang benar + entri_baru = { + 'tanggal': transaksi['tanggal'], + 'kode_barang': kode_barang, + 'nama_barang': transaksi['nama_barang'], + 'masuk_qty': masuk_qty, + 'masuk_harga': masuk_harga, + 'masuk_total': masuk_total, + 'keluar_qty': keluar_qty, + 'keluar_harga': keluar_harga, + 'keluar_total': keluar_total, + 'saldo_qty': saldo_qty, + 'saldo_harga': saldo_harga, + 'saldo_total': saldo_total, + 'keterangan': transaksi['keterangan'] + } + + entri_baru['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_kartu_persediaan.append(entri_baru) + print(f"🔁 {kode_barang} Transaksi {i+1}: Saldo = {saldo_qty}kg @ Rp{saldo_harga:,.0f}") + + print("✅ SELESAI menghitung ulang semua saldo") + +# Callback untuk menampilkan jurnal umum dengan format yang rapi +@app.callback( + [Output('tabel-jurnal-umum', 'children'), + Output('rekapitulasi-jurnal', 'children')], + Input('btn-refresh-jurnal', 'n_clicks') +) +def update_jurnal_umum(n_clicks): + # Tampilkan jurnal umum dalam format yang rapi + if sibal_data.jurnal_umum: + jurnal_entries = [] + + for jurnal in sibal_data.jurnal_umum: + # Entry untuk debit + jurnal_entries.append(html.Div([ + html.Span(jurnal['tanggal'], style={'fontWeight': 'bold', 'minWidth': '100px', 'display': 'inline-block'}), + html.Span(jurnal['akun_debit'], style={'minWidth': '200px', 'display': 'inline-block'}), + html.Span("", style={'minWidth': '100px', 'display': 'inline-block'}), + html.Span(format_rupiah(jurnal['jumlah_debit']), + style={'textAlign': 'right', 'minWidth': '150px', 'display': 'inline-block', 'paddingLeft': '20px'}), + html.Span("", style={'minWidth': '150px', 'display': 'inline-block'}) + ], className="jurnal-entry")) + + # Entry untuk kredit (dengan indentasi) + jurnal_entries.append(html.Div([ + html.Span("", style={'minWidth': '100px', 'display': 'inline-block'}), + html.Span(jurnal['akun_kredit'], + style={'minWidth': '200px', 'display': 'inline-block', 'paddingLeft': '40px', 'color': COLORS['secondary']}), + html.Span("", style={'minWidth': '100px', 'display': 'inline-block'}), + html.Span("", style={'minWidth': '150px', 'display': 'inline-block'}), + html.Span(format_rupiah(jurnal['jumlah_kredit']), + style={'textAlign': 'right', 'minWidth': '150px', 'display': 'inline-block', 'color': COLORS['secondary']}) + ], className="jurnal-entry jurnal-kredit")) + + # Tambahkan spasi antara jurnal + jurnal_entries.append(html.Div(style={'height': '10px'})) + + tabel_jurnal = html.Div(jurnal_entries) + + # Buat rekapitulasi jurnal + rekapitulasi_data = {} + for jurnal in sibal_data.jurnal_umum: + # Debit + akun_debit = f"{jurnal['kode_akun_debit']} - {jurnal['akun_debit']}" + if akun_debit not in rekapitulasi_data: + rekapitulasi_data[akun_debit] = {'debit': 0, 'kredit': 0} + rekapitulasi_data[akun_debit]['debit'] += jurnal['jumlah_debit'] + + # Kredit + akun_kredit = f"{jurnal['kode_akun_kredit']} - {jurnal['akun_kredit']}" + if akun_kredit not in rekapitulasi_data: + rekapitulasi_data[akun_kredit] = {'debit': 0, 'kredit': 0} + rekapitulasi_data[akun_kredit]['kredit'] += jurnal['jumlah_kredit'] + + # Buat tabel rekapitulasi + header_rekapitulasi = html.Tr([ + html.Th("Akun Debit"), + html.Th("Kode"), + html.Th("Debit"), + html.Th("Akun Kredit"), + html.Th("Kode"), + html.Th("Kredit") + ]) + + rows_rekapitulasi = [] + total_debit = 0 + total_kredit = 0 + + # Gabungkan data debit dan kredit dalam satu baris + akun_list = list(rekapitulasi_data.keys()) + max_rows = max(len([akun for akun in akun_list if rekapitulasi_data[akun]['debit'] > 0]), + len([akun for akun in akun_list if rekapitulasi_data[akun]['kredit'] > 0])) + + debit_akun = [akun for akun in akun_list if rekapitulasi_data[akun]['debit'] > 0] + kredit_akun = [akun for akun in akun_list if rekapitulasi_data[akun]['kredit'] > 0] + + for i in range(max_rows): + debit_row = debit_akun[i] if i < len(debit_akun) else "" + kredit_row = kredit_akun[i] if i < len(kredit_akun) else "" + + debit_kode = debit_row.split(' - ')[0] if debit_row else "" + debit_nama = debit_row.split(' - ')[1] if debit_row else "" + debit_jumlah = rekapitulasi_data[debit_row]['debit'] if debit_row else 0 + + kredit_kode = kredit_row.split(' - ')[0] if kredit_row else "" + kredit_nama = kredit_row.split(' - ')[1] if kredit_row else "" + kredit_jumlah = rekapitulasi_data[kredit_row]['kredit'] if kredit_row else 0 + + total_debit += debit_jumlah + total_kredit += kredit_jumlah + + rows_rekapitulasi.append(html.Tr([ + html.Td(debit_nama), + html.Td(debit_kode), + html.Td(format_rupiah(debit_jumlah) if debit_jumlah > 0 else ""), + html.Td(kredit_nama), + html.Td(kredit_kode), + html.Td(format_rupiah(kredit_jumlah) if kredit_jumlah > 0 else "") + ])) + + # Baris total + rows_rekapitulasi.append(html.Tr([ + html.Td("TOTAL", colSpan=2, style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}), + html.Td(format_rupiah(total_debit), style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}), + html.Td("TOTAL", colSpan=2, style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}), + html.Td(format_rupiah(total_kredit), style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}) + ])) + + tabel_rekapitulasi = html.Table( + [header_rekapitulasi] + rows_rekapitulasi, + className="modern-table" + ) + + return tabel_jurnal, tabel_rekapitulasi + else: + return html.P("Belum ada data jurnal umum", style={'color': '#6c757d'}), html.P("Belum ada data rekapitulasi", style={'color': '#6c757d'}) + +@app.callback( + [Output('summary-akumulasi', 'children'), + Output('selected-date', 'date')], + [Input('btn-akumulasi-pemasukan', 'n_clicks'), + Input('btn-akumulasi-pengeluaran', 'n_clicks')], + [State('selected-date', 'date')], + prevent_initial_call=True +) +def akumulasi_ke_jurnal(n_pemasukan, n_pengeluaran, tanggal): + ctx = dash.callback_context + if not ctx.triggered: + return html.P("Klik tombol akumulasi untuk mencatat transaksi ke jurnal umum"), dash.no_update + + button_id = ctx.triggered[0]['prop_id'].split('.')[0] + jurnal_created = [] + + print(f"🔧 DEBUG: Processing accumulation for {button_id} on date {tanggal}") + + # Hitung tanggal berikutnya + if tanggal: + current_date = datetime.strptime(tanggal, '%Y-%m-%d') + next_date = current_date + timedelta(days=1) + next_date_str = next_date.strftime('%Y-%m-%d') + else: + next_date_str = datetime.now().date().isoformat() + + if button_id == 'btn-akumulasi-pemasukan' and n_pemasukan: + # Akumulasi pemasukan + transaksi_pemasukan = [t for t in sibal_data.transaksi_pemasukan if t['tanggal'] == tanggal] + + print(f"📊 DEBUG: Found {len(transaksi_pemasukan)} pemasukan transactions") + + for trans in transaksi_pemasukan: + print(f"🔍 DEBUG: Processing pemasukan - {trans['jenis']} - Rp {trans['jumlah']:,}") + + if trans['jenis'] == 'tiket_masuk': + jurnal = { + 'tanggal': tanggal, + 'keterangan': f"{trans['keterangan']}", + 'ref': trans.get('ref', ''), + 'akun_debit': 'Kas', + 'kode_akun_debit': KODE_AKUN['kas']['kode'], + 'jumlah_debit': trans['jumlah'], + 'akun_kredit': 'Pendapatan Tiket', + 'kode_akun_kredit': KODE_AKUN['pendapatan_tiket']['kode'], + 'jumlah_kredit': trans['jumlah'] + } + jurnal['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_jurnal_umum.append(jurnal) + jurnal_created.append(jurnal) + print(f"✅ Created jurnal for tiket masuk") + # Dalam callback akumulasi_ke_jurnal, perbaiki bagian penjualan ikan: + elif trans['jenis'] == 'penjualan_ikan': + # Jurnal penjualan + jurnal_penjualan = { + 'tanggal': tanggal, + 'keterangan': f"{trans['keterangan']}", + 'ref': trans.get('ref', ''), + 'akun_debit': 'Kas', + 'kode_akun_debit': KODE_AKUN['kas']['kode'], + 'jumlah_debit': trans['jumlah'], + 'akun_kredit': 'Penjualan', + 'kode_akun_kredit': KODE_AKUN['pendapatan']['kode'], + 'jumlah_kredit': trans['jumlah'] + } + jurnal_penjualan['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_jurnal_umum.append(jurnal_penjualan) + jurnal_created.append(jurnal_penjualan) + print(f"✅ Created jurnal for penjualan") + + # Jurnal HPP dan pengurangan persediaan + kode_barang = 'IK001' # Ikan Bawal Segar + qty_terjual = trans.get('quantity', 0) + + print(f"🔧 DEBUG: Attempting to reduce inventory - {qty_terjual}kg") + + if qty_terjual > 0: + # Kurangi persediaan fisik dengan fungsi yang sudah diperbaiki + success, total_hpp = update_persediaan_penjualan(kode_barang, qty_terjual, tanggal, trans['keterangan']) + + if success: + print(f"✅ Successfully reduced inventory, HPP: Rp{total_hpp:,}") + + # Buat jurnal HPP + jurnal_hpp = { + 'tanggal': tanggal, + 'keterangan': f"HPP - {trans['keterangan']}", + 'ref': trans.get('ref', '') + '-HPP', + 'akun_debit': 'Harga Pokok Produksi', + 'kode_akun_debit': KODE_AKUN['hpp']['kode'], + 'jumlah_debit': total_hpp, + 'akun_kredit': 'Persediaan Barang Dagang', + 'kode_akun_kredit': KODE_AKUN['persediaan']['kode'], + 'jumlah_kredit': total_hpp + } + jurnal_hpp['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_jurnal_umum.append(jurnal_hpp) + jurnal_created.append(jurnal_hpp) + print(f"✅ Created HPP jurnal: Rp {total_hpp:,}") + else: + print(f"❌ Failed to reduce inventory") + + elif trans['jenis'] == 'modal': + jurnal = { + 'tanggal': tanggal, + 'keterangan': f"{trans['keterangan']}", + 'ref': trans.get('ref', ''), + 'akun_debit': 'Kas', + 'kode_akun_debit': KODE_AKUN['kas']['kode'], + 'jumlah_debit': trans['jumlah'], + 'akun_kredit': 'Modal', + 'kode_akun_kredit': KODE_AKUN['modal']['kode'], + 'jumlah_kredit': trans['jumlah'] + } + jurnal['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_jurnal_umum.append(jurnal) + jurnal_created.append(jurnal) + print(f"✅ Created jurnal for modal") + + # Simpan data setelah semua jurnal dibuat + if jurnal_created: + sibal_data.save_all_data() + return html.Div([ + html.H4("✅ Akumulasi Pemasukan Berhasil!", style={'color': '#28a745'}), + html.P(f"{len(jurnal_created)} jurnal telah dibuat untuk transaksi pemasukan tanggal {tanggal}"), + html.P(f"Tanggal otomatis berubah ke {next_date_str}", style={'fontWeight': 'bold', 'color': '#007bff'}) + ]), next_date_str + else: + return html.Div([ + html.H4("❌ Tidak ada transaksi pemasukan", style={'color': '#dc3545'}), + html.P(f"Tidak ada transaksi pemasukan untuk tanggal {tanggal}"), + html.P("Silakan buat transaksi pemasukan terlebih dahulu") + ]), dash.no_update + + elif button_id == 'btn-akumulasi-pengeluaran' and n_pengeluaran: + # Akumulasi pengeluaran + transaksi_pengeluaran = [t for t in sibal_data.transaksi_pengeluaran if t['tanggal'] == tanggal] + + print(f"📊 DEBUG: Found {len(transaksi_pengeluaran)} pengeluaran transactions") + + for trans in transaksi_pengeluaran: + print(f"🔍 DEBUG: Processing pengeluaran - {trans['jenis']} - Rp {trans['jumlah']:,}") + + # Tentukan akun debit berdasarkan jenis pengeluaran + akun_debit_mapping = { + 'pembelian_persediaan': {'nama': 'Persediaan Barang Dagang', 'kode': KODE_AKUN['persediaan']['kode']}, + 'beban_gaji': {'nama': 'Beban Gaji', 'kode': KODE_AKUN['beban_gaji']['kode']}, + 'beban_listrik': {'nama': 'Beban Listrik', 'kode': KODE_AKUN['beban_listrik']['kode']}, + 'beban_penyusutan_bangunan': {'nama': 'Beban Depresiasi Bangunan', 'kode': KODE_AKUN['beban_penyusutan_bangunan']['kode']}, + 'beban_penyusutan_kendaraan': {'nama': 'Beban Depresiasi Kendaraan', 'kode': KODE_AKUN['beban_penyusutan_kendaraan']['kode']}, + 'beban_penyusutan_peralatan': {'nama': 'Beban Depresiasi Peralatan', 'kode': KODE_AKUN['beban_penyusutan_peralatan']['kode']}, + 'beban_lainnya': {'nama': 'Beban Lainnya', 'kode': KODE_AKUN['beban_lainnya']['kode']} + } + + akun_debit = akun_debit_mapping.get(trans['jenis'], {'nama': 'Beban Lainnya', 'kode': KODE_AKUN['beban_lainnya']['kode']}) + + # Tentukan akun kredit berdasarkan metode pembayaran + if trans.get('metode_bayar') == 'tunai': + akun_kredit = {'nama': 'Kas', 'kode': KODE_AKUN['kas']['kode']} + else: + akun_kredit = {'nama': 'Utang Dagang', 'kode': KODE_AKUN['utang']['kode']} + + jurnal = { + 'tanggal': tanggal, + 'keterangan': f"{trans['keterangan']}", + 'ref': trans.get('ref', ''), + 'akun_debit': akun_debit['nama'], + 'kode_akun_debit': akun_debit['kode'], + 'jumlah_debit': trans['jumlah'], + 'akun_kredit': akun_kredit['nama'], + 'kode_akun_kredit': akun_kredit['kode'], + 'jumlah_kredit': trans['jumlah'] + } + + jurnal['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_jurnal_umum.append(jurnal) + jurnal_created.append(jurnal) + print(f"✅ Created jurnal for {trans['jenis']}") + + # TAMBAHKAN KE BUKU BESAR PEMBANTU JIKA KREDIT + if trans.get('metode_bayar') == 'kredit' and trans.get('supplier'): + supplier = trans['supplier'] + print(f"🔧 DEBUG: Adding to buku besar pembantu for supplier: {supplier}") + + # Inisialisasi jika belum ada + if supplier not in sibal_data.buku_besar_pembantu: + sibal_data.buku_besar_pembantu[supplier] = [] + + # Hitung saldo terakhir + saldo_terakhir = 0 + if sibal_data.buku_besar_pembantu[supplier]: + saldo_terakhir = sibal_data.buku_besar_pembantu[supplier][-1]['saldo'] + + saldo_baru = saldo_terakhir + trans['jumlah'] + + # Tambahkan transaksi + sibal_data.buku_besar_pembantu[supplier].append({ + 'tanggal': tanggal, + 'keterangan': f"{trans['jenis']} - {trans['keterangan']}", + 'debit': 0, + 'kredit': trans['jumlah'], + 'saldo': saldo_baru + }) + print(f"✅ Added to buku besar pembantu: {supplier} - Rp {trans['jumlah']:,}") + + # Simpan data setelah semua jurnal dibuat + if jurnal_created: + sibal_data.save_all_data() + return html.Div([ + html.H4("✅ Akumulasi Pengeluaran Berhasil!", style={'color': '#28a745'}), + html.P(f"{len(jurnal_created)} jurnal telah dibuat untuk transaksi pengeluaran tanggal {tanggal}"), + html.P(f"Tanggal otomatis berubah ke {next_date_str}", style={'fontWeight': 'bold', 'color': '#007bff'}) + ]), next_date_str + else: + return html.Div([ + html.H4("❌ Tidak ada transaksi pengeluaran", style={'color': '#dc3545'}), + html.P(f"Tidak ada transaksi pengeluaran untuk tanggal {tanggal}"), + html.P("Silakan buat transaksi pengeluaran terlebih dahulu") + ]), dash.no_update + + return html.P("Klik tombol akumulasi untuk mencatat transaksi ke jurnal"), dash.no_update + +# Fungsi untuk akumulasi pengeluaran ke jurnal +def akumulasi_pengeluaran_ke_jurnal(tanggal): + transaksi_hari_ini = [t for t in sibal_data.transaksi_pengeluaran if t['tanggal'] == tanggal] + + if transaksi_hari_ini: + for trans in transaksi_hari_ini: + jenis = trans['jenis'] + jumlah = trans['jumlah'] + metode = trans['metode_bayar'] + keterangan = trans['keterangan'] + ref = trans.get('ref', '') + + # Tentukan akun berdasarkan kode akun yang sudah disimpan + akun_debit = trans.get('kode_akun_debit', '') + akun_debit_nama = next((v['nama'] for k, v in KODE_AKUN.items() if v['kode'] == akun_debit), 'Beban Lainnya') + + akun_kredit = trans.get('kode_akun_kredit', '') + akun_kredit_nama = next((v['nama'] for k, v in KODE_AKUN.items() if v['kode'] == akun_kredit), 'Kas') + + # Buat jurnal + entri_jurnal = { + 'tanggal': tanggal, + 'keterangan': f"{keterangan}", + 'ref': ref, + 'akun_debit': akun_debit_nama, + 'kode_akun_debit': akun_debit, + 'jumlah_debit': jumlah, + 'akun_kredit': akun_kredit_nama, + 'kode_akun_kredit': akun_kredit, + 'jumlah_kredit': jumlah + } + entri_jurnal['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_jurnal_umum.append(entri_jurnal) + + sibal_data.save_data() + return html.Div([ + html.H4("✅ Akumulasi Berhasil!", style={'color': '#28a745'}), + html.P(f"Transaksi pengeluaran tanggal {tanggal} telah dicatat ke Jurnal Umum") + ]) + + return html.P("Belum ada transaksi pengeluaran untuk diakumulasi") + +@app.callback( + Output('tabel-kartu-persediaan', 'children'), + Input('btn-refresh-persediaan', 'n_clicks') +) +def update_kartu_persediaan(n_clicks): + """Update kartu persediaan dengan perhitungan real-time""" + print(f"🔄 Memperbarui kartu persediaan...") + + # HITUNG ULANG SALDO SEBELUM MENAMPILKAN + hitung_ulang_semua_saldo() + + if sibal_data.kartu_persediaan: + # Kelompokkan berdasarkan kode barang + transaksi_per_barang = {} + for item in sibal_data.kartu_persediaan: + kode = item['kode_barang'] + if kode not in transaksi_per_barang: + transaksi_per_barang[kode] = [] + transaksi_per_barang[kode].append(item) + + all_tables = [] + + for kode_barang, transaksi_list in transaksi_per_barang.items(): + # Urutkan transaksi berdasarkan tanggal + transaksi_list.sort(key=lambda x: x['tanggal']) + + barang_pertama = transaksi_list[0] + + # BUAT HEADER + header = html.Tr([ + html.Th("Tanggal", style={'backgroundColor': COLORS['primary'], 'color': 'white', 'padding': '8px'}), + html.Th("Keterangan", style={'backgroundColor': COLORS['primary'], 'color': 'white', 'padding': '8px'}), + html.Th("Masuk", style={'backgroundColor': COLORS['success'], 'color': 'white', 'padding': '8px', 'textAlign': 'center'}), + html.Th("", style={'backgroundColor': COLORS['success'], 'color': 'white', 'padding': '8px', 'textAlign': 'center'}), + html.Th("", style={'backgroundColor': COLORS['success'], 'color': 'white', 'padding': '8px', 'textAlign': 'center'}), + html.Th("Keluar", style={'backgroundColor': COLORS['error'], 'color': 'white', 'padding': '8px', 'textAlign': 'center'}), + html.Th("", style={'backgroundColor': COLORS['error'], 'color': 'white', 'padding': '8px', 'textAlign': 'center'}), + html.Th("", style={'backgroundColor': COLORS['error'], 'color': 'white', 'padding': '8px', 'textAlign': 'center'}), + html.Th("Saldo", style={'backgroundColor': COLORS['info'], 'color': 'white', 'padding': '8px', 'textAlign': 'center'}), + html.Th("", style={'backgroundColor': COLORS['info'], 'color': 'white', 'padding': '8px', 'textAlign': 'center'}), + html.Th("", style={'backgroundColor': COLORS['info'], 'color': 'white', 'padding': '8px', 'textAlign': 'center'}) + ]) + + sub_header = html.Tr([ + html.Th("", style={'backgroundColor': COLORS['primary'], 'color': 'white'}), + html.Th("", style={'backgroundColor': COLORS['primary'], 'color': 'white'}), + html.Th("Qty", style={'backgroundColor': COLORS['success_light'], 'color': 'white', 'textAlign': 'center'}), + html.Th("Harga", style={'backgroundColor': COLORS['success_light'], 'color': 'white', 'textAlign': 'center'}), + html.Th("Total", style={'backgroundColor': COLORS['success_light'], 'color': 'white', 'textAlign': 'center'}), + html.Th("Qty", style={'backgroundColor': COLORS['error_light'], 'color': 'white', 'textAlign': 'center'}), + html.Th("Harga", style={'backgroundColor': COLORS['error_light'], 'color': 'white', 'textAlign': 'center'}), + html.Th("Total", style={'backgroundColor': COLORS['error_light'], 'color': 'white', 'textAlign': 'center'}), + html.Th("Qty", style={'backgroundColor': COLORS['info_light'], 'color': 'white', 'textAlign': 'center'}), + html.Th("Harga", style={'backgroundColor': COLORS['info_light'], 'color': 'white', 'textAlign': 'center'}), + html.Th("Total", style={'backgroundColor': COLORS['info_light'], 'color': 'white', 'textAlign': 'center'}) + ]) + + rows = [] + total_masuk_qty = 0 + total_masuk_nilai = 0 + total_keluar_qty = 0 + total_keluar_nilai = 0 + + for item in transaksi_list: + def format_angka(angka): + return f"{angka:,.0f}" if angka > 0 else "" + + def format_rupiah(angka): + return f"Rp{angka:,.0f}".replace(",", ".") if angka > 0 else "" + + rows.append(html.Tr([ + html.Td(item['tanggal'], style={'padding': '6px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'fontSize': '12px'}), + html.Td(item['keterangan'], style={'padding': '6px', 'border': '1px solid #dee2e6', 'fontSize': '12px'}), + html.Td(format_angka(item['masuk_qty']), style={'padding': '6px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#e8f5e8', 'fontSize': '12px'}), + html.Td(format_rupiah(item['masuk_harga']), style={'padding': '6px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#e8f5e8', 'fontSize': '12px'}), + html.Td(format_rupiah(item['masuk_total']), style={'padding': '6px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#e8f5e8', 'fontSize': '12px'}), + html.Td(format_angka(item['keluar_qty']), style={'padding': '6px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#f8d7da', 'fontSize': '12px'}), + html.Td(format_rupiah(item['keluar_harga']), style={'padding': '6px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#f8d7da', 'fontSize': '12px'}), + html.Td(format_rupiah(item['keluar_total']), style={'padding': '6px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#f8d7da', 'fontSize': '12px'}), + html.Td(format_angka(item['saldo_qty']), style={'padding': '6px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#d1ecf1', 'fontWeight': 'bold', 'fontSize': '12px'}), + html.Td(format_rupiah(item['saldo_harga']), style={'padding': '6px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#d1ecf1', 'fontSize': '12px'}), + html.Td(format_rupiah(item['saldo_total']), style={'padding': '6px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#d1ecf1', 'fontWeight': 'bold', 'fontSize': '12px'}) + ])) + + total_masuk_qty += item['masuk_qty'] + total_masuk_nilai += item['masuk_total'] + total_keluar_qty += item['keluar_qty'] + total_keluar_nilai += item['keluar_total'] + + # BARIS TOTAL + last_trans = transaksi_list[-1] + rows.append(html.Tr([ + html.Td("TOTAL", colSpan=2, style={'padding': '8px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'borderTop': '2px solid #000', 'fontSize': '12px'}), + html.Td(f"{total_masuk_qty}", style={'padding': '8px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'borderTop': '2px solid #000', 'textAlign': 'right', 'fontSize': '12px'}), + html.Td("", style={'padding': '8px', 'border': '1px solid #dee2e6', 'borderTop': '2px solid #000'}), + html.Td(format_rupiah(total_masuk_nilai), style={'padding': '8px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'borderTop': '2px solid #000', 'textAlign': 'right', 'fontSize': '12px'}), + html.Td(f"{total_keluar_qty}", style={'padding': '8px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'borderTop': '2px solid #000', 'textAlign': 'right', 'fontSize': '12px'}), + html.Td("", style={'padding': '8px', 'border': '1px solid #dee2e6', 'borderTop': '2px solid #000'}), + html.Td(format_rupiah(total_keluar_nilai), style={'padding': '8px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'borderTop': '2px solid #000', 'textAlign': 'right', 'fontSize': '12px'}), + html.Td(f"{last_trans['saldo_qty']}", style={'padding': '8px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'borderTop': '2px solid #000', 'textAlign': 'right', 'fontSize': '12px'}), + html.Td(format_rupiah(last_trans['saldo_harga']), style={'padding': '8px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'borderTop': '2px solid #000', 'textAlign': 'right', 'fontSize': '12px'}), + html.Td(format_rupiah(last_trans['saldo_total']), style={'padding': '8px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'borderTop': '2px solid #000', 'textAlign': 'right', 'fontSize': '12px'}) + ])) + + table = html.Div([ + html.H4(f"📦 {kode_barang} - {barang_pertama['nama_barang']}", + style={'color': COLORS['primary'], 'marginBottom': '10px', 'fontSize': '16px'}), + html.Table( + [header, sub_header] + rows, + style={ + 'width': '100%', + 'borderCollapse': 'collapse', + 'border': '1px solid #dee2e6', + 'fontSize': '11px' + } + ) + ], style={'marginBottom': '20px', 'overflowX': 'auto'}) + + all_tables.append(table) + + return html.Div(all_tables) + else: + return html.Div([ + html.P("📭 Belum ada data kartu persediaan", + style={'textAlign': 'center', 'color': COLORS['gray_600'], 'fontSize': '16px', 'marginBottom': '10px'}), + html.P("💡 Buat transaksi pembelian persediaan terlebih dahulu", + style={'textAlign': 'center', 'color': COLORS['gray_500'], 'fontSize': '14px'}) + ]) + +@app.callback( + Output('detail-buku-besar', 'children'), + Input('dropdown-akun-buku-besar', 'value') +) +def update_buku_besar(akun): + if akun: + # Cari nama akun dari value + akun_nama = next((item['label'].split(' - ')[1] for item in sibal_data.daftar_akun if item['value'] == akun), akun) + akun_kode = next((item['label'].split(' - ')[0] for item in sibal_data.daftar_akun if item['value'] == akun), "") + + # Hitung saldo + saldo = 0 + transaksi_akun = [] + + # Gabungkan semua jurnal + semua_jurnal = sibal_data.jurnal_umum + sibal_data.jurnal_penyesuaian + + for jurnal in semua_jurnal: + if jurnal['kode_akun_debit'] == akun_kode: + saldo += jurnal['jumlah_debit'] + transaksi_akun.append({ + 'tanggal': jurnal['tanggal'], + 'keterangan': jurnal['keterangan'], + 'debit': jurnal['jumlah_debit'], + 'kredit': 0, + 'saldo': saldo + }) + elif jurnal['kode_akun_kredit'] == akun_kode: + saldo -= jurnal['jumlah_kredit'] + transaksi_akun.append({ + 'tanggal': jurnal['tanggal'], + 'keterangan': jurnal['keterangan'], + 'debit': 0, + 'kredit': jurnal['jumlah_kredit'], + 'saldo': saldo + }) + + if transaksi_akun: + header = html.Tr([ + html.Th("Tanggal"), + html.Th("Keterangan"), + html.Th("Debit"), + html.Th("Kredit"), + html.Th("Saldo") + ]) + + rows = [] + for trans in transaksi_akun: + rows.append(html.Tr([ + html.Td(trans['tanggal']), + html.Td(trans['keterangan']), + html.Td(format_rupiah(trans['debit']) if trans['debit'] > 0 else ""), + html.Td(format_rupiah(trans['kredit']) if trans['kredit'] > 0 else ""), + html.Td(format_rupiah(trans['saldo'])) + ])) + + return html.Div([ + html.H4(f"Buku Besar: {akun_nama} ({akun_kode})", style={'marginBottom': '20px'}), + html.Table([header] + rows, className="modern-table") + ]) + else: + return html.P(f"Belum ada transaksi untuk akun {akun_nama}", style={'color': '#6c757d'}) + else: + return html.P("Pilih akun untuk melihat buku besar", style={'color': '#6c757d'}) + +# Tambahkan callback khusus untuk auto-refresh setelah transaksi +@app.callback( + Output('btn-refresh-persediaan', 'children'), # Update tombol refresh sebagai trigger + [Input('btn-tambah-pengeluaran', 'n_clicks'), + Input('btn-akumulasi-pengeluaran', 'n_clicks')], + prevent_initial_call=True +) +def trigger_persediaan_refresh(n_pengeluaran, n_akumulasi): + """Trigger refresh kartu persediaan setelah transaksi""" + ctx = dash.callback_context + if ctx.triggered: + print(f"🔄 Triggering persediaan refresh from {ctx.triggered[0]['prop_id']}") + return "🔄 Refresh Data (Updated)" + return "🔄 Refresh Data" + +@app.callback( + [Output('daftar-supplier', 'children'), + Output('detail-buku-besar-pembantu', 'children')], + Input('btn-refresh-buku-pembantu', 'n_clicks') +) +def refresh_buku_besar_pembantu(n_clicks): + """Refresh data buku besar pembantu""" + print(f"🔧 DEBUG: Refreshing buku besar pembantu...") + + # PROSES DATA UTANG DARI TRANSAKSI KREDIT + sibal_data.buku_besar_pembantu = {} # Reset dulu + + # Kumpulkan semua supplier dari transaksi kredit + suppliers_set = set() + for trans in sibal_data.transaksi_pengeluaran: + if trans.get('metode_bayar') == 'kredit' and trans.get('supplier'): + suppliers_set.add(trans['supplier']) + + sibal_data.suppliers = list(suppliers_set) + + # Proses transaksi untuk setiap supplier + for supplier in sibal_data.suppliers: + sibal_data.buku_besar_pembantu[supplier] = [] + + # Cari semua transaksi kredit untuk supplier ini + transaksi_supplier = [t for t in sibal_data.transaksi_pengeluaran + if t.get('supplier') == supplier and t.get('metode_bayar') == 'kredit'] + + saldo = 0 + for trans in transaksi_supplier: + jumlah = trans['jumlah'] + saldo += jumlah + + # Tambahkan transaksi utang + sibal_data.buku_besar_pembantu[supplier].append({ + 'tanggal': trans['tanggal'], + 'keterangan': f"{trans['jenis']} - {trans['keterangan']}", + 'debit': 0, # Pembayaran akan mengurangi utang (debit) + 'kredit': jumlah, # Utang bertambah (kredit) + 'saldo': saldo + }) + + # Daftar supplier + if sibal_data.suppliers: + daftar_supplier = html.Div([ + html.H4("📋 Daftar Supplier dengan Utang", style={'color': COLORS['primary'], 'marginBottom': '20px'}), + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-building", style={'fontSize': '2rem', 'color': COLORS['secondary']}), + html.Div([ + html.Strong(supplier, style={'fontSize': '1.1rem'}), + html.Br(), + html.Small(f"Total Utang: {format_rupiah(sum(t['kredit'] - t['debit'] for t in sibal_data.buku_besar_pembantu.get(supplier, [])))}") + ], style={'marginLeft': '10px'}) + ], style={'display': 'flex', 'alignItems': 'center', 'padding': '15px'}) + ], style={ + 'backgroundColor': COLORS['white'], + 'borderRadius': '10px', + 'boxShadow': '0 2px 5px rgba(0,0,0,0.1)', + 'marginBottom': '10px', + 'borderLeft': f'4px solid {COLORS["secondary"]}' + }) + for supplier in sibal_data.suppliers + if supplier in sibal_data.buku_besar_pembantu and sibal_data.buku_besar_pembantu[supplier] + ]) + ]) + else: + daftar_supplier = html.Div([ + html.I(className="fas fa-info-circle", style={'fontSize': '3rem', 'color': COLORS['gray_400'], 'display': 'block', 'textAlign': 'center', 'marginBottom': '10px'}), + html.P("Belum ada supplier dengan utang", style={'textAlign': 'center', 'color': COLORS['gray_600']}) + ]) + + # Detail per supplier + detail_supplier = [] + for supplier in sibal_data.suppliers: + if supplier in sibal_data.buku_besar_pembantu and sibal_data.buku_besar_pembantu[supplier]: + transaksi = sibal_data.buku_besar_pembantu[supplier] + + header = html.Tr([ + html.Th("Tanggal", style={'backgroundColor': COLORS['primary'], 'color': 'white'}), + html.Th("Keterangan", style={'backgroundColor': COLORS['primary'], 'color': 'white'}), + html.Th("Pembayaran (Debit)", style={'backgroundColor': COLORS['primary'], 'color': 'white'}), + html.Th("Utang (Kredit)", style={'backgroundColor': COLORS['primary'], 'color': 'white'}), + html.Th("Saldo Utang", style={'backgroundColor': COLORS['primary'], 'color': 'white'}) + ]) + + rows = [] + saldo = 0 + for t in transaksi: + saldo = saldo + t['kredit'] - t['debit'] + rows.append(html.Tr([ + html.Td(t['tanggal']), + html.Td(t['keterangan']), + html.Td(format_rupiah(t['debit']) if t['debit'] > 0 else "-", + style={'color': COLORS['success'], 'textAlign': 'right'}), + html.Td(format_rupiah(t['kredit']) if t['kredit'] > 0 else "-", + style={'color': COLORS['error'], 'textAlign': 'right'}), + html.Td(format_rupiah(saldo), + style={ + 'fontWeight': 'bold', + 'color': COLORS['error'] if saldo > 0 else COLORS['success'], + 'textAlign': 'right' + }) + ])) + + total_utang = saldo + detail_supplier.append(html.Div([ + html.H4(f"📊 Buku Besar Pembantu: {supplier}", + style={'color': COLORS['secondary'], 'marginBottom': '20px'}), + html.Table([header] + rows, className="modern-table"), + html.Div([ + html.Strong(f"Total Utang: {format_rupiah(total_utang)}"), + ], style={ + 'padding': '15px', + 'backgroundColor': COLORS['error_light'] if total_utang > 0 else COLORS['success_light'], + 'color': 'white', + 'borderRadius': '8px', + 'textAlign': 'center', + 'marginTop': '15px', + 'fontSize': '1.1rem' + }), + html.Hr(style={'margin': '30px 0', 'borderColor': COLORS['gray_300']}) + ], style={'marginBottom': '30px'})) + + if not detail_supplier: + detail_supplier = html.Div([ + html.I(className="fas fa-receipt", style={'fontSize': '3rem', 'color': COLORS['gray_400'], 'display': 'block', 'textAlign': 'center', 'marginBottom': '10px'}), + html.P("Belum ada transaksi utang", style={'textAlign': 'center', 'color': COLORS['gray_600']}) + ]) + else: + detail_supplier = html.Div(detail_supplier) + + return daftar_supplier, detail_supplier + +def bayar_utang_supplier(supplier, jumlah, tanggal, keterangan=""): + """Mencatat pembayaran utang kepada supplier""" + try: + if supplier not in sibal_data.buku_besar_pembantu: + print(f"❌ Supplier {supplier} tidak ditemukan") + return False + + # Hitung saldo terakhir + saldo_terakhir = 0 + if sibal_data.buku_besar_pembantu[supplier]: + saldo_terakhir = sibal_data.buku_besar_pembantu[supplier][-1]['saldo'] + + if jumlah > saldo_terakhir: + print(f"❌ Jumlah pembayaran melebihi utang. Utang: {saldo_terakhir}, Bayar: {jumlah}") + return False + + saldo_baru = saldo_terakhir - jumlah + + # Tambahkan transaksi pembayaran + sibal_data.buku_besar_pembantu[supplier].append({ + 'tanggal': tanggal, + 'keterangan': keterangan if keterangan else f'Pembayaran utang', + 'debit': jumlah, # Pembayaran mengurangi utang + 'kredit': 0, + 'saldo': saldo_baru + }) + + # Buat jurnal untuk pembayaran + jurnal_pembayaran = { + 'tanggal': tanggal, + 'keterangan': f'Pembayaran utang kepada {supplier}', + 'ref': f'PAY-{supplier}', + 'akun_debit': KODE_AKUN['utang']['nama'], + 'kode_akun_debit': KODE_AKUN['utang']['kode'], + 'jumlah_debit': jumlah, + 'akun_kredit': KODE_AKUN['kas']['nama'], + 'kode_akun_kredit': KODE_AKUN['kas']['kode'], + 'jumlah_kredit': jumlah + } + jurnal_pembayaran['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_jurnal_umum.append(jurnal_pembayaran) + + print(f"✅ Pembayaran utang {supplier}: Rp{jumlah:,}, sisa: Rp{saldo_baru:,}") + sibal_data.save_all_data() + return True + + except Exception as e: + print(f"❌ Error bayar utang: {e}") + return False + +@app.callback( + Output('hidden-trigger-buku-pembantu', 'children'), + [Input('url', 'pathname'), + Input('btn-refresh-buku-pembantu', 'n_clicks')], + prevent_initial_call=True +) +def trigger_buku_pembantu_update(pathname, n_clicks): + """Trigger untuk memuat ulang data buku besar pembantu""" + if pathname == '/buku-besar-pembantu' or n_clicks: + # Process ulang data utang dari transaksi + sibal_data.buku_besar_pembantu = {} + sibal_data.suppliers = [] + + # Kumpulkan semua supplier dari transaksi kredit + suppliers_set = set() + for trans in sibal_data.transaksi_pengeluaran: + if trans.get('metode_bayar') == 'kredit' and trans.get('supplier'): + suppliers_set.add(trans['supplier']) + + sibal_data.suppliers = list(suppliers_set) + + # Process transaksi untuk setiap supplier + for supplier in sibal_data.suppliers: + sibal_data.buku_besar_pembantu[supplier] = [] + + # Cari semua transaksi kredit untuk supplier ini + transaksi_supplier = [t for t in sibal_data.transaksi_pengeluaran + if t.get('supplier') == supplier and t.get('metode_bayar') == 'kredit'] + + saldo = 0 + for trans in transaksi_supplier: + jumlah = trans['jumlah'] + saldo += jumlah + + # Tambahkan transaksi utang + sibal_data.buku_besar_pembantu[supplier].append({ + 'tanggal': trans['tanggal'], + 'keterangan': f"{trans['jenis']} - {trans['keterangan']}", + 'debit': 0, + 'kredit': jumlah, + 'saldo': saldo + }) + + return f"updated-{datetime.now().timestamp()}" + + return dash.no_update + +# Callback untuk menyimpan aset tetap +@app.callback( + [Output('nilai-aset', 'value'), + Output('masa-manfaat', 'value'), + Output('nilai-residu', 'value')], + Input('btn-simpan-aset', 'n_clicks'), + [State('jenis-aset', 'value'), + State('nilai-aset', 'value'), + State('tanggal-perolehan', 'date'), + State('masa-manfaat', 'value'), + State('metode-penyusutan', 'value'), + State('nilai-residu', 'value')], + prevent_initial_call=True +) +def simpan_aset_tetap(n_clicks, jenis_aset, nilai_aset, tanggal_perolehan, masa_manfaat, metode, nilai_residu): + if n_clicks and n_clicks > 0 and jenis_aset and nilai_aset and masa_manfaat: + # Simpan ke data aset tetap + sibal_data.aset_tetap[jenis_aset] = { + 'nilai_awal': nilai_aset, + 'penyusutan': 0, # Akan dihitung nanti + 'masa_manfaat': masa_manfaat, + 'tahun_pembelian': datetime.fromisoformat(tanggal_perolehan).year, + 'metode_penyusutan': metode, + 'nilai_residu': nilai_residu if nilai_residu else 0, + 'tanggal_perolehan': tanggal_perolehan + } + + # Buat jurnal untuk pembelian aset + if jenis_aset == 'tanah': + kode_akun_debit = KODE_AKUN['tanah']['kode'] + nama_akun_debit = KODE_AKUN['tanah']['nama'] + elif jenis_aset == 'bangunan_gazebo': + kode_akun_debit = KODE_AKUN['bangunan_gazebo']['kode'] + nama_akun_debit = KODE_AKUN['bangunan_gazebo']['nama'] + elif jenis_aset == 'kendaraan': + kode_akun_debit = KODE_AKUN['kendaraan']['kode'] + nama_akun_debit = KODE_AKUN['kendaraan']['nama'] + else: # peralatan + kode_akun_debit = KODE_AKUN['peralatan']['kode'] + nama_akun_debit = KODE_AKUN['peralatan']['nama'] + + # Jurnal pembelian aset + entri_jurnal = { + 'tanggal': tanggal_perolehan, + 'keterangan': f'Pembelian {nama_akun_debit}', + 'ref': f'AST-{jenis_aset.upper()}', + 'akun_debit': nama_akun_debit, + 'kode_akun_debit': kode_akun_debit, + 'jumlah_debit': nilai_aset, + 'akun_kredit': KODE_AKUN['kas']['nama'], + 'kode_akun_kredit': KODE_AKUN['kas']['kode'], + 'jumlah_kredit': nilai_aset + } + entri_jurnal['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_jurnal_umum.append(entri_jurnal) + + sibal_data.save_data() + return '', '', '' + + return dash.no_update + +# Callback untuk menampilkan daftar aset tetap +@app.callback( + Output('daftar-aset-tetap', 'children'), + [Input('btn-simpan-aset', 'n_clicks'), + Input('btn-hitung-penyusutan', 'n_clicks')] +) +def update_daftar_aset(n_clicks_simpan, n_clicks_hitung): + if sibal_data.aset_tetap: + header = html.Tr([ + html.Th("Jenis Aset"), + html.Th("Nilai Awal"), + html.Th("Penyusutan"), + html.Th("Nilai Buku"), + html.Th("Masa Manfaat"), + html.Th("Tahun Pembelian") + ]) + + rows = [] + for jenis_aset, data in sibal_data.aset_tetap.items(): + if data['nilai_awal'] > 0: # Hanya tampilkan aset yang sudah diisi + nilai_buku = data['nilai_awal'] - data['penyusutan'] + rows.append(html.Tr([ + html.Td(jenis_aset.replace('_', ' ').title()), + html.Td(format_rupiah(data['nilai_awal'])), + html.Td(format_rupiah(data['penyusutan'])), + html.Td(format_rupiah(nilai_buku)), + html.Td(f"{data['masa_manfaat']} tahun"), + html.Td(data['tahun_pembelian']) + ])) + + if rows: + return html.Table([header] + rows, className="modern-table") + + return html.P("Belum ada data aset tetap", style={'color': '#6c757d'}) + +@app.callback( + Output('tabel-neraca-saldo', 'children'), + Input('btn-generate-neraca-saldo', 'n_clicks'), + State('tanggal-neraca-saldo', 'date'), + prevent_initial_call=True +) +def generate_neraca_saldo(n_clicks, tanggal): + if n_clicks and n_clicks > 0: + # Hitung saldo setiap akun dari jurnal umum + saldo_akun = {} + + for jurnal in sibal_data.jurnal_umum: + # Proses akun debit + kode_debit = jurnal['kode_akun_debit'] + if kode_debit not in saldo_akun: + saldo_akun[kode_debit] = {'debit': 0, 'kredit': 0, 'nama': jurnal['akun_debit']} + saldo_akun[kode_debit]['debit'] += jurnal['jumlah_debit'] + + # Proses akun kredit + kode_kredit = jurnal['kode_akun_kredit'] + if kode_kredit not in saldo_akun: + saldo_akun[kode_kredit] = {'debit': 0, 'kredit': 0, 'nama': jurnal['akun_kredit']} + saldo_akun[kode_kredit]['kredit'] += jurnal['jumlah_kredit'] + + # Buat tabel neraca saldo + header = html.Tr([ + html.Th("Kode Akun"), + html.Th("Nama Akun"), + html.Th("Debit"), + html.Th("Kredit") + ]) + + rows = [] + total_debit = 0 + total_kredit = 0 + + for kode_akun, data in sorted(saldo_akun.items()): + saldo_debit = max(0, data['debit'] - data['kredit']) + saldo_kredit = max(0, data['kredit'] - data['debit']) + + total_debit += saldo_debit + total_kredit += saldo_kredit + + rows.append(html.Tr([ + html.Td(kode_akun), + html.Td(data['nama']), + html.Td(format_rupiah(saldo_debit) if saldo_debit > 0 else ""), + html.Td(format_rupiah(saldo_kredit) if saldo_kredit > 0 else "") + ])) + + # Baris total + rows.append(html.Tr([ + html.Td("TOTAL", colSpan=2, style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}), + html.Td(format_rupiah(total_debit), style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}), + html.Td(format_rupiah(total_kredit), style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}) + ])) + + return html.Table([header] + rows, className="modern-table") + + return html.P("Klik 'Generate Neraca Saldo' untuk melihat neraca saldo", style={'color': '#6c757d'}) + +# Callback untuk menghitung penyusutan +@app.callback( + Output('kalkulator-penyusutan', 'children'), + Input('btn-hitung-penyusutan', 'n_clicks'), + prevent_initial_call=True +) +def hitung_penyusutan(n_clicks): + if n_clicks and n_clicks > 0: + jurnal_penyesuaian_aset = [] + + for jenis_aset, data in sibal_data.aset_tetap.items(): + if data['nilai_awal'] > 0 and jenis_aset != 'tanah': # Tanah tidak disusutkan + # Hitung penyusutan tahunan dengan metode garis lurus + nilai_penyusutan = data['nilai_awal'] / data['masa_manfaat'] + + # Update data penyusutan + sibal_data.aset_tetap[jenis_aset]['penyusutan'] += nilai_penyusutan + + # Buat jurnal penyesuaian untuk penyusutan + if jenis_aset == 'bangunan_gazebo': + kode_akun_beban = KODE_AKUN['beban_penyusutan_bangunan']['kode'] + nama_akun_beban = KODE_AKUN['beban_penyusutan_bangunan']['nama'] + kode_akun_akumulasi = KODE_AKUN['akumulasi_penyusutan_bangunan']['kode'] + nama_akun_akumulasi = KODE_AKUN['akumulasi_penyusutan_bangunan']['nama'] + elif jenis_aset == 'kendaraan': + kode_akun_beban = KODE_AKUN['beban_penyusutan_kendaraan']['kode'] + nama_akun_beban = KODE_AKUN['beban_penyusutan_kendaraan']['nama'] + kode_akun_akumulasi = KODE_AKUN['akumulasi_penyusutan_kendaraan']['kode'] + nama_akun_akumulasi = KODE_AKUN['akumulasi_penyusutan_kendaraan']['nama'] + else: # peralatan + kode_akun_beban = KODE_AKUN['beban_penyusutan_peralatan']['kode'] + nama_akun_beban = KODE_AKUN['beban_penyusutan_peralatan']['nama'] + kode_akun_akumulasi = KODE_AKUN['akumulasi_penyusutan_peralatan']['kode'] + nama_akun_akumulasi = KODE_AKUN['akumulasi_penyusutan_peralatan']['nama'] + + jurnal_penyesuaian = { + 'tanggal': datetime.now().date().isoformat(), + 'keterangan': f'Penyusutan {jenis_aset.replace("_", " ").title()}', + 'ref': f'DEP-{jenis_aset.upper()}', + 'akun_debit': nama_akun_beban, + 'kode_akun_debit': kode_akun_beban, + 'jumlah_debit': nilai_penyusutan, + 'akun_kredit': nama_akun_akumulasi, + 'kode_akun_kredit': kode_akun_akumulasi, + 'jumlah_kredit': nilai_penyusutan + } + jurnal_penyesuaian_aset.append(jurnal_penyesuaian) + jurnal_penyesuaian['user_id'] = current_user.get_id() if current_user and current_user.get_id() else None + sibal_data._all_jurnal_penyesuaian.append(jurnal_penyesuaian) + + sibal_data.save_data() + + # Tampilkan hasil perhitungan + if jurnal_penyesuaian_aset: + header = html.Tr([ + html.Th("Jenis Aset"), + html.Th("Penyusutan Tahunan"), + html.Th("Akun Beban"), + html.Th("Akun Akumulasi") + ]) + + rows = [] + for jurnal in jurnal_penyesuaian_aset: + jenis_aset = jurnal['keterangan'].split(' ')[1] + rows.append(html.Tr([ + html.Td(jenis_aset), + html.Td(format_rupiah(jurnal['jumlah_debit'])), + html.Td(jurnal['akun_debit']), + html.Td(jurnal['akun_kredit']) + ])) + + return html.Div([ + html.H4("✅ Penyusutan Berhasil Dihitung!", style={'color': '#28a745'}), + html.Table([header] + rows, className="modern-table") + ]) + + return html.P("Klik 'Hitung Penyusutan' untuk menghitung penyusutan aset", style={'color': '#6c757d'}) + +@app.callback( + Output('daftar-jurnal-penyesuaian', 'children'), + Input('btn-generate-penyesuaian', 'n_clicks'), + State('tanggal-penyesuaian', 'date'), + prevent_initial_call=True +) +def generate_jurnal_penyesuaian(n_clicks, tanggal): + if n_clicks and n_clicks > 0: + # Otomatis buat jurnal penyesuaian untuk penyusutan aset + jurnal_penyesuaian = [] + + # 1. Jurnal penyesuaian untuk penyusutan aset + for jenis_aset, data in sibal_data.aset_tetap.items(): + if data['nilai_awal'] > 0 and jenis_aset != 'tanah': + # Hitung penyusutan + nilai_penyusutan = data['nilai_awal'] / data['masa_manfaat'] + + if jenis_aset == 'bangunan_gazebo': + akun_beban = KODE_AKUN['beban_penyusutan_bangunan']['nama'] + akun_akumulasi = KODE_AKUN['akumulasi_penyusutan_bangunan']['nama'] + elif jenis_aset == 'kendaraan': + akun_beban = KODE_AKUN['beban_penyusutan_kendaraan']['nama'] + akun_akumulasi = KODE_AKUN['akumulasi_penyusutan_kendaraan']['nama'] + else: # peralatan + akun_beban = KODE_AKUN['beban_penyusutan_peralatan']['nama'] + akun_akumulasi = KODE_AKUN['akumulasi_penyusutan_peralatan']['nama'] + + jurnal_penyesuaian.append({ + 'tanggal': tanggal, + 'keterangan': f'Penyusutan {jenis_aset.replace("_", " ").title()}', + 'akun_debit': akun_beban, + 'debit': nilai_penyusutan, + 'akun_kredit': akun_akumulasi, + 'kredit': nilai_penyusutan + }) + + # 2. Jurnal penyesuaian untuk beban yang masih harus dibayar + # (Contoh: Beban gaji yang belum dibayar) + total_beban_gaji = sum(t['jumlah'] for t in sibal_data.transaksi_pengeluaran + if t['jenis'] == 'beban_gaji' and t['metode_bayar'] == 'kredit') + if total_beban_gaji > 0: + jurnal_penyesuaian.append({ + 'tanggal': tanggal, + 'keterangan': 'Beban gaji yang masih harus dibayar', + 'akun_debit': KODE_AKUN['beban_gaji']['nama'], + 'debit': total_beban_gaji, + 'akun_kredit': KODE_AKUN['utang_gaji']['nama'], + 'kredit': total_beban_gaji + }) + + # Tampilkan jurnal penyesuaian + if jurnal_penyesuaian: + header = html.Tr([ + html.Th("Tanggal"), + html.Th("Keterangan"), + html.Th("Akun Debit"), + html.Th("Debit"), + html.Th("Akun Kredit"), + html.Th("Kredit") + ]) + + rows = [] + for jurnal in jurnal_penyesuaian: + rows.append(html.Tr([ + html.Td(jurnal['tanggal']), + html.Td(jurnal['keterangan']), + html.Td(jurnal['akun_debit']), + html.Td(format_rupiah(jurnal['debit'])), + html.Td(jurnal['akun_kredit']), + html.Td(format_rupiah(jurnal['kredit'])) + ])) + + return html.Table([header] + rows, className="modern-table") + else: + return html.P("Tidak ada jurnal penyesuaian yang diperlukan", style={'color': '#6c757d'}) + + return html.P("Klik 'Generate Jurnal Penyesuaian' untuk membuat jurnal penyesuaian", style={'color': '#6c757d'}) + +@app.callback( + Output('tabel-neraca-penyesuaian', 'children'), + Input('btn-generate-neraca-penyesuaian', 'n_clicks'), + State('tanggal-neraca-penyesuaian', 'date'), + prevent_initial_call=True +) +def generate_neraca_setelah_penyesuaian(n_clicks, tanggal): + if n_clicks and n_clicks > 0: + # Hitung saldo setiap akun dari jurnal umum + jurnal penyesuaian + saldo_akun = {} + + # Proses jurnal umum + for jurnal in sibal_data.jurnal_umum: + kode_debit = jurnal['kode_akun_debit'] + if kode_debit not in saldo_akun: + saldo_akun[kode_debit] = { + 'debit': 0, + 'kredit': 0, + 'nama': jurnal['akun_debit'], + 'tipe': 'aktiva' if kode_debit.startswith('1') else 'pasiva' if kode_debit.startswith('2') else 'modal' if kode_debit.startswith('3') else 'pendapatan' if kode_debit.startswith('4') else 'beban' + } + saldo_akun[kode_debit]['debit'] += jurnal['jumlah_debit'] + + kode_kredit = jurnal['kode_akun_kredit'] + if kode_kredit not in saldo_akun: + saldo_akun[kode_kredit] = { + 'debit': 0, + 'kredit': 0, + 'nama': jurnal['akun_kredit'], + 'tipe': 'aktiva' if kode_kredit.startswith('1') else 'pasiva' if kode_kredit.startswith('2') else 'modal' if kode_kredit.startswith('3') else 'pendapatan' if kode_kredit.startswith('4') else 'beban' + } + saldo_akun[kode_kredit]['kredit'] += jurnal['jumlah_kredit'] + + # Proses jurnal penyesuaian + for jurnal in sibal_data.jurnal_penyesuaian: + kode_debit = jurnal['kode_akun_debit'] + if kode_debit not in saldo_akun: + saldo_akun[kode_debit] = { + 'debit': 0, + 'kredit': 0, + 'nama': jurnal['akun_debit'], + 'tipe': 'aktiva' if kode_debit.startswith('1') else 'pasiva' if kode_debit.startswith('2') else 'modal' if kode_debit.startswith('3') else 'pendapatan' if kode_debit.startswith('4') else 'beban' + } + saldo_akun[kode_debit]['debit'] += jurnal['jumlah_debit'] + + kode_kredit = jurnal['kode_akun_kredit'] + if kode_kredit not in saldo_akun: + saldo_akun[kode_kredit] = { + 'debit': 0, + 'kredit': 0, + 'nama': jurnal['akun_kredit'], + 'tipe': 'aktiva' if kode_kredit.startswith('1') else 'pasiva' if kode_kredit.startswith('2') else 'modal' if kode_kredit.startswith('3') else 'pendapatan' if kode_kredit.startswith('4') else 'beban' + } + saldo_akun[kode_kredit]['kredit'] += jurnal['jumlah_kredit'] + + # Kelompokkan akun berdasarkan tipe + aktiva = {k: v for k, v in saldo_akun.items() if v['tipe'] == 'aktiva'} + pasiva = {k: v for k, v in saldo_akun.items() if v['tipe'] == 'pasiva'} + modal = {k: v for k, v in saldo_akun.items() if v['tipe'] == 'modal'} + pendapatan = {k: v for k, v in saldo_akun.items() if v['tipe'] == 'pendapatan'} + beban = {k: v for k, v in saldo_akun.items() if v['tipe'] == 'beban'} + + # Hitung total aktiva + total_aktiva = 0 + rows_aktiva = [] + + for kode, data in sorted(aktiva.items()): + saldo = data['debit'] - data['kredit'] + if saldo != 0: # Hanya tampilkan akun dengan saldo + total_aktiva += saldo + rows_aktiva.append(html.Tr([ + html.Td(kode), + html.Td(data['nama']), + html.Td(format_rupiah(saldo)) + ])) + + # Hitung total pasiva + modal + total_pasiva_modal = 0 + rows_pasiva = [] + rows_modal = [] + + for kode, data in sorted(pasiva.items()): + saldo = data['kredit'] - data['debit'] + if saldo != 0: + total_pasiva_modal += saldo + rows_pasiva.append(html.Tr([ + html.Td(kode), + html.Td(data['nama']), + html.Td(format_rupiah(saldo)) + ])) + + for kode, data in sorted(modal.items()): + saldo = data['kredit'] - data['debit'] + if saldo != 0: + total_pasiva_modal += saldo + rows_modal.append(html.Tr([ + html.Td(kode), + html.Td(data['nama']), + html.Td(format_rupiah(saldo)) + ])) + + # Hitung laba/rugi dari pendapatan dan beban + total_pendapatan = sum(data['kredit'] - data['debit'] for data in pendapatan.values()) + total_beban = sum(data['debit'] - data['kredit'] for data in beban.values()) + laba_rugi = total_pendapatan - total_beban + + # Tambahkan laba/rugi ke modal + if laba_rugi != 0: + total_pasiva_modal += laba_rugi + rows_modal.append(html.Tr([ + html.Td(""), + html.Td("Laba (Rugi) Berjalan", style={'fontStyle': 'italic'}), + html.Td(format_rupiah(laba_rugi)) + ])) + + # Buat tabel neraca + return html.Div([ + html.H4(f"Neraca Setelah Penyesuaian per {tanggal}", + style={'textAlign': 'center', 'marginBottom': '30px', 'color': COLORS['primary']}), + + html.Div([ + html.Div([ + html.H5("AKTIVA", style={'color': COLORS['primary'], 'marginBottom': '20px'}), + html.Table([ + html.Tr([html.Th("Kode"), html.Th("Nama Akun"), html.Th("Saldo")]) + ] + rows_aktiva + [ + html.Tr([ + html.Td("", colSpan=2, style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}), + html.Td(format_rupiah(total_aktiva), + style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}) + ]) + ], className="modern-table", style={'width': '100%'}) + ], style={'flex': 1, 'marginRight': '20px'}), + + html.Div([ + html.H5("PASIVA & MODAL", style={'color': COLORS['secondary'], 'marginBottom': '20px'}), + + html.H6("Kewajiban", style={'color': COLORS['gray_600'], 'marginTop': '15px'}), + html.Table([ + html.Tr([html.Th("Kode"), html.Th("Nama Akun"), html.Th("Saldo")]) + ] + rows_pasiva, className="modern-table", style={'width': '100%'}), + + html.H6("Modal", style={'color': COLORS['gray_600'], 'marginTop': '15px'}), + html.Table([ + html.Tr([html.Th("Kode"), html.Th("Nama Akun"), html.Th("Saldo")]) + ] + rows_modal + [ + html.Tr([ + html.Td("", colSpan=2, style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}), + html.Td(format_rupiah(total_pasiva_modal), + style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}) + ]) + ], className="modern-table", style={'width': '100%'}) + ], style={'flex': 1}) + ], style={'display': 'flex', 'gap': '20px'}), + + html.Div([ + html.H5("KESEIMBANGAN NERACA", style={ + 'textAlign': 'center', + 'color': COLORS['success'] if abs(total_aktiva - total_pasiva_modal) < 0.01 else COLORS['error'], + 'marginTop': '30px' + }), + html.P(f"Total Aktiva: {format_rupiah(total_aktiva)}", + style={'textAlign': 'center', 'fontWeight': 'bold'}), + html.P(f"Total Pasiva + Modal: {format_rupiah(total_pasiva_modal)}", + style={'textAlign': 'center', 'fontWeight': 'bold'}), + html.P("✅ Neraca Seimbang" if abs(total_aktiva - total_pasiva_modal) < 0.01 else "❌ Neraca Tidak Seimbang", + style={'textAlign': 'center', 'fontWeight': 'bold', + 'color': COLORS['success'] if abs(total_aktiva - total_pasiva_modal) < 0.01 else COLORS['error']}) + ]) + ]) + + return html.P("Klik 'Generate Neraca Setelah Penyesuaian' untuk melihat neraca", style={'color': '#6c757d'}) + +@app.callback( + Output('tabel-laba-rugi', 'children'), + Input('btn-generate-laba-rugi', 'n_clicks'), + State('tanggal-laba-rugi', 'date'), + prevent_initial_call=True +) +def generate_laporan_laba_rugi(n_clicks, tanggal): + if n_clicks and n_clicks > 0: + # Hitung saldo setiap akun dari jurnal umum + jurnal penyesuaian + saldo_akun = {} + + # Proses semua jurnal + semua_jurnal = sibal_data.jurnal_umum + sibal_data.jurnal_penyesuaian + + for jurnal in semua_jurnal: + # Akun debit + kode_debit = jurnal['kode_akun_debit'] + if kode_debit not in saldo_akun: + saldo_akun[kode_debit] = { + 'debit': 0, + 'kredit': 0, + 'nama': jurnal['akun_debit'], + 'tipe': 'pendapatan' if kode_debit.startswith('4') else 'beban' if kode_debit.startswith('5') or kode_debit.startswith('6') else 'lainnya' + } + saldo_akun[kode_debit]['debit'] += jurnal['jumlah_debit'] + + # Akun kredit + kode_kredit = jurnal['kode_akun_kredit'] + if kode_kredit not in saldo_akun: + saldo_akun[kode_kredit] = { + 'debit': 0, + 'kredit': 0, + 'nama': jurnal['akun_kredit'], + 'tipe': 'pendapatan' if kode_kredit.startswith('4') else 'beban' if kode_kredit.startswith('5') or kode_kredit.startswith('6') else 'lainnya' + } + saldo_akun[kode_kredit]['kredit'] += jurnal['jumlah_kredit'] + + # Kelompokkan pendapatan dan beban + pendapatan = {k: v for k, v in saldo_akun.items() if v['tipe'] == 'pendapatan'} + beban = {k: v for k, v in saldo_akun.items() if v['tipe'] == 'beban'} + + # Hitung total pendapatan + total_pendapatan = 0 + rows_pendapatan = [] + + for kode, data in sorted(pendapatan.items()): + saldo = data['kredit'] - data['debit'] # Pendapatan di kredit + if saldo != 0: + total_pendapatan += saldo + rows_pendapatan.append(html.Tr([ + html.Td(kode), + html.Td(data['nama']), + html.Td(format_rupiah(saldo)) + ])) + + # Hitung total beban + total_beban = 0 + rows_beban = [] + + for kode, data in sorted(beban.items()): + saldo = data['debit'] - data['kredit'] # Beban di debit + if saldo != 0: + total_beban += saldo + rows_beban.append(html.Tr([ + html.Td(kode), + html.Td(data['nama']), + html.Td(format_rupiah(saldo)) + ])) + + # Hitung laba/rugi + laba_rugi = total_pendapatan - total_beban + margin_keuntungan = (laba_rugi / total_pendapatan * 100) if total_pendapatan > 0 else 0 + + return html.Div([ + html.H4(f"Laporan Laba Rugi per {tanggal}", + style={'textAlign': 'center', 'marginBottom': '30px', 'color': COLORS['primary']}), + + # Bagian Pendapatan + html.Div([ + html.H5("PENDAPATAN", style={'color': COLORS['success'], 'marginBottom': '15px'}), + html.Table([ + html.Tr([html.Th("Kode"), html.Th("Jenis Pendapatan"), html.Th("Jumlah")]) + ] + rows_pendapatan + [ + html.Tr([ + html.Td("", colSpan=2, style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}), + html.Td(format_rupiah(total_pendapatan), + style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}) + ]) + ], className="modern-table") + ], style={'marginBottom': '30px'}), + + # Bagian Beban + html.Div([ + html.H5("BEBAN", style={'color': COLORS['error'], 'marginBottom': '15px'}), + html.Table([ + html.Tr([html.Th("Kode"), html.Th("Jenis Beban"), html.Th("Jumlah")]) + ] + rows_beban + [ + html.Tr([ + html.Td("", colSpan=2, style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}), + html.Td(format_rupiah(total_beban), + style={'fontWeight': 'bold', 'borderTop': '2px solid #000'}) + ]) + ], className="modern-table") + ], style={'marginBottom': '30px'}), + + # Hasil Laba/Rugi + html.Div([ + html.H4("HASIL USAHA", style={ + 'textAlign': 'center', + 'color': COLORS['success'] if laba_rugi >= 0 else COLORS['error'], + 'padding': '20px', + 'backgroundColor': COLORS['accent_mint'] if laba_rugi >= 0 else COLORS['accent_mint_light'], + 'borderRadius': '15px', + 'border': f'2px solid {COLORS["success"] if laba_rugi >= 0 else COLORS["error"]}' + }), + + html.Div([ + html.Div([ + html.P("Total Pendapatan", style={'fontWeight': 'bold'}), + html.P(format_rupiah(total_pendapatan), style={'fontSize': '1.2rem', 'color': COLORS['success']}) + ], style={'textAlign': 'center', 'flex': 1}), + + html.Div([ + html.P("➖", style={'fontSize': '2rem', 'margin': '0 20px'}) + ], style={'display': 'flex', 'alignItems': 'center'}), + + html.Div([ + html.P("Total Beban", style={'fontWeight': 'bold'}), + html.P(format_rupiah(total_beban), style={'fontSize': '1.2rem', 'color': COLORS['error']}) + ], style={'textAlign': 'center', 'flex': 1}), + + html.Div([ + html.P("🟰", style={'fontSize': '2rem', 'margin': '0 20px'}) + ], style={'display': 'flex', 'alignItems': 'center'}), + + html.Div([ + html.P("Laba (Rugi) Bersih", style={'fontWeight': 'bold'}), + html.P(format_rupiah(abs(laba_rugi)), + style={'fontSize': '1.5rem', 'fontWeight': 'bold', + 'color': COLORS['success'] if laba_rugi >= 0 else COLORS['error']}) + ], style={'textAlign': 'center', 'flex': 1}) + ], style={'display': 'flex', 'justifyContent': 'center', 'alignItems': 'center', 'marginTop': '20px'}), + + html.Div([ + html.P(f"Margin Keuntungan: {margin_keuntungan:.1f}%", + style={'textAlign': 'center', 'fontSize': '1.1rem', 'color': COLORS['gray_600']}) + ], style={'marginTop': '15px'}) + ]) + ]) + + return html.P("Klik 'Generate Laporan Laba Rugi' untuk melihat laporan", style={'color': '#6c757d'}) + +def neraca_lajur_layout(): + return html.Div([ + html.Div([ + html.Div([ + html.Div([ + html.I(className="fas fa-table") + ], className="card-icon"), + html.H1("Neraca Lajur", className="card-title"), + html.P("Worksheet - Laporan Keuangan Komprehensif", className="card-subtitle") + ], className="card-header"), + + html.Div([ + html.Div([ + html.Label("Periode Neraca Lajur:", className="form-label"), + dcc.DatePickerRange( + id='periode-neraca-lajur', + start_date=datetime.now().date().replace(day=1), # awal bulan + end_date=datetime.now().date(), + display_format='YYYY-MM-DD', + className="form-control" + ) + ], className="form-group", style={'marginBottom': '20px'}), + + html.Button( + "📊 Generate Neraca Lajur", + id='btn-generate-neraca-lajur', + className="btn btn-primary", + style={'marginBottom': '20px'} + ), + + html.Div(id='tabel-neraca-lajur', className="table-container") + + ], className="card-content") + ], className="glass-card") + ], className="main-container") + +# Callback untuk generate neraca lajur +@app.callback( + Output('tabel-neraca-lajur', 'children'), + Input('btn-generate-neraca-lajur', 'n_clicks'), + [State('periode-neraca-lajur', 'start_date'), + State('periode-neraca-lajur', 'end_date')] +) +def generate_neraca_lajur(n_clicks, start_date, end_date): + if n_clicks and n_clicks > 0: + return create_neraca_lajur_table(start_date, end_date) + + return html.P("Pilih periode dan klik 'Generate Neraca Lajur'", style={'color': '#6c757d'}) + +def create_neraca_lajur_table(start_date, end_date): + """Membuat tabel neraca lajur dari data aktual""" + + # Hitung saldo dari semua akun sebelum penyesuaian (hanya jurnal umum) + saldo_neraca_saldo = calculate_saldo_akun_periode(sibal_data.jurnal_umum, start_date, end_date) + + # Hitung saldo penyesuaian (hanya jurnal penyesuaian) + saldo_penyesuaian = calculate_saldo_akun_periode(sibal_data.jurnal_penyesuaian, start_date, end_date) + + # Hitung saldo setelah penyesuaian (gabungan jurnal umum + penyesuaian) + saldo_setelah_penyesuaian = calculate_saldo_akun_periode( + sibal_data.jurnal_umum + sibal_data.jurnal_penyesuaian, start_date, end_date + ) + + # Klasifikasikan akun untuk laba rugi dan posisi keuangan + akun_laba_rugi = ['pendapatan', 'pendapatan_tiket', 'hpp', 'beban_gaji', 'beban_listrik', + 'beban_penyusutan_bangunan', 'beban_penyusutan_kendaraan', + 'beban_penyusutan_peralatan', 'beban_lainnya'] + + akun_posisi_keuangan = ['kas', 'persediaan', 'perlengkapan', 'peralatan', 'tanah', + 'bangunan_gazebo', 'akumulasi_penyusutan_bangunan', 'kendaraan', + 'akumulasi_penyusutan_kendaraan', 'akumulasi_penyusutan_peralatan', + 'utang', 'utang_gaji', 'modal'] + + # Header tabel + header = html.Tr([ + html.Th("Kode Akun", rowSpan=2, style={'backgroundColor': '#343a40', 'color': 'white', 'padding': '10px', 'minWidth': '80px'}), + html.Th("Nama Akun", rowSpan=2, style={'backgroundColor': '#343a40', 'color': 'white', 'padding': '10px', 'minWidth': '200px'}), + html.Th("Neraca Saldo Sebelum Penyesuaian", colSpan=2, style={'backgroundColor': '#007bff', 'color': 'white', 'textAlign': 'center'}), + html.Th("Penyesuaian", colSpan=2, style={'backgroundColor': '#28a745', 'color': 'white', 'textAlign': 'center'}), + html.Th("Neraca Saldo Setelah Penyesuaian", colSpan=2, style={'backgroundColor': '#ffc107', 'color': 'white', 'textAlign': 'center'}), + html.Th("Laporan Laba Rugi", colSpan=2, style={'backgroundColor': '#dc3545', 'color': 'white', 'textAlign': 'center'}), + html.Th("Laporan Posisi Keuangan", colSpan=2, style={'backgroundColor': '#6f42c1', 'color': 'white', 'textAlign': 'center'}) + ]) + + sub_header = html.Tr([ + html.Th("", style={'backgroundColor': '#343a40', 'color': 'white'}), # Kode Akun + html.Th("", style={'backgroundColor': '#343a40', 'color': 'white'}), # Nama Akun + html.Th("Debit", style={'backgroundColor': '#007bff', 'color': 'white', 'textAlign': 'right', 'minWidth': '120px'}), + html.Th("Kredit", style={'backgroundColor': '#007bff', 'color': 'white', 'textAlign': 'right', 'minWidth': '120px'}), + html.Th("Debit", style={'backgroundColor': '#28a745', 'color': 'white', 'textAlign': 'right', 'minWidth': '120px'}), + html.Th("Kredit", style={'backgroundColor': '#28a745', 'color': 'white', 'textAlign': 'right', 'minWidth': '120px'}), + html.Th("Debit", style={'backgroundColor': '#ffc107', 'color': 'white', 'textAlign': 'right', 'minWidth': '120px'}), + html.Th("Kredit", style={'backgroundColor': '#ffc107', 'color': 'white', 'textAlign': 'right', 'minWidth': '120px'}), + html.Th("Debit", style={'backgroundColor': '#dc3545', 'color': 'white', 'textAlign': 'right', 'minWidth': '120px'}), + html.Th("Kredit", style={'backgroundColor': '#dc3545', 'color': 'white', 'textAlign': 'right', 'minWidth': '120px'}), + html.Th("Debit", style={'backgroundColor': '#6f42c1', 'color': 'white', 'textAlign': 'right', 'minWidth': '120px'}), + html.Th("Kredit", style={'backgroundColor': '#6f42c1', 'color': 'white', 'textAlign': 'right', 'minWidth': '120px'}) + ]) + + rows = [] + + # Kumpulkan semua akun yang ada saldo + semua_akun = set(saldo_neraca_saldo.keys()).union( + set(saldo_penyesuaian.keys()), + set(saldo_setelah_penyesuaian.keys()) + ) + + # Urutkan akun berdasarkan kode + akun_terurut = [] + for akun in semua_akun: + kode = KODE_AKUN.get(akun, {}).get('kode', '') + akun_terurut.append((kode, akun)) + akun_terurut.sort() + + # Variabel untuk total + total_debit_neraca = 0 + total_kredit_neraca = 0 + total_debit_penyesuaian = 0 + total_kredit_penyesuaian = 0 + total_debit_setelah = 0 + total_kredit_setelah = 0 + total_debit_laba_rugi = 0 + total_kredit_laba_rugi = 0 + total_debit_posisi = 0 + total_kredit_posisi = 0 + + # Buat baris untuk setiap akun + for kode, akun in akun_terurut: + nama_akun = KODE_AKUN.get(akun, {}).get('nama', akun) + + # Saldo neraca saldo (sebelum penyesuaian) + saldo_ns = saldo_neraca_saldo.get(akun, 0) + debit_ns = saldo_ns if saldo_ns > 0 else 0 + kredit_ns = abs(saldo_ns) if saldo_ns < 0 else 0 + + # Saldo penyesuaian + saldo_pen = saldo_penyesuaian.get(akun, 0) + debit_pen = saldo_pen if saldo_pen > 0 else 0 + kredit_pen = abs(saldo_pen) if saldo_pen < 0 else 0 + + # Saldo setelah penyesuaian + saldo_setelah = saldo_setelah_penyesuaian.get(akun, 0) + debit_setelah = saldo_setelah if saldo_setelah > 0 else 0 + kredit_setelah = abs(saldo_setelah) if saldo_setelah < 0 else 0 + + # Klasifikasi untuk laba rugi dan posisi keuangan + if akun in akun_laba_rugi: + debit_lr = debit_setelah + kredit_lr = kredit_setelah + debit_pk = 0 + kredit_pk = 0 + elif akun in akun_posisi_keuangan: + debit_lr = 0 + kredit_lr = 0 + debit_pk = debit_setelah + kredit_pk = kredit_setelah + else: + # Default: masukkan ke posisi keuangan + debit_lr = 0 + kredit_lr = 0 + debit_pk = debit_setelah + kredit_pk = kredit_setelah + + # Tambahkan ke total + total_debit_neraca += debit_ns + total_kredit_neraca += kredit_ns + total_debit_penyesuaian += debit_pen + total_kredit_penyesuaian += kredit_pen + total_debit_setelah += debit_setelah + total_kredit_setelah += kredit_setelah + total_debit_laba_rugi += debit_lr + total_kredit_laba_rugi += kredit_lr + total_debit_posisi += debit_pk + total_kredit_posisi += kredit_pk + + # Buat baris + rows.append(html.Tr([ + html.Td(kode, style={'padding': '8px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold'}), + html.Td(nama_akun, style={'padding': '8px', 'border': '1px solid #dee2e6'}), + html.Td(format_currency(debit_ns), style={'padding': '8px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#e3f2fd'}), + html.Td(format_currency(kredit_ns), style={'padding': '8px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#e3f2fd'}), + html.Td(format_currency(debit_pen), style={'padding': '8px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#e8f5e8'}), + html.Td(format_currency(kredit_pen), style={'padding': '8px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#e8f5e8'}), + html.Td(format_currency(debit_setelah), style={'padding': '8px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#fff3cd'}), + html.Td(format_currency(kredit_setelah), style={'padding': '8px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#fff3cd'}), + html.Td(format_currency(debit_lr), style={'padding': '8px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#f8d7da'}), + html.Td(format_currency(kredit_lr), style={'padding': '8px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#f8d7da'}), + html.Td(format_currency(debit_pk), style={'padding': '8px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#e2e3f5'}), + html.Td(format_currency(kredit_pk), style={'padding': '8px', 'border': '1px solid #dee2e6', 'textAlign': 'right', 'backgroundColor': '#e2e3f5'}) + ])) + + # Baris jumlah + rows.append(html.Tr([ + html.Td("", colSpan=2, style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'center', 'backgroundColor': '#e9ecef'}, children=["Jumlah"]), + html.Td(format_currency(total_debit_neraca), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_kredit_neraca), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_debit_penyesuaian), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_kredit_penyesuaian), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_debit_setelah), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_kredit_setelah), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_debit_laba_rugi), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_kredit_laba_rugi), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_debit_posisi), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_kredit_posisi), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}) + ])) + + # Baris laba (rugi) bersih + laba_bersih = total_kredit_laba_rugi - total_debit_laba_rugi + rows.append(html.Tr([ + html.Td("", colSpan=8, style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'center', 'backgroundColor': '#f8f9fa'}, children=["Laba (Rugi) Bersih"]), + html.Td(format_currency(laba_bersih), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#f8f9fa'}), + html.Td("", style={'padding': '10px', 'border': '1px solid #dee2e6', 'backgroundColor': '#f8f9fa'}), + html.Td(format_currency(laba_bersih), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#f8f9fa'}), + html.Td("", style={'padding': '10px', 'border': '1px solid #dee2e6', 'backgroundColor': '#f8f9fa'}) + ])) + + # Baris jumlah akhir + rows.append(html.Tr([ + html.Td("", colSpan=8, style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'center', 'backgroundColor': '#e9ecef'}, children=["Jumlah"]), + html.Td(format_currency(total_kredit_laba_rugi), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_kredit_laba_rugi), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_debit_posisi + laba_bersih), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}), + html.Td(format_currency(total_kredit_posisi + laba_bersih), style={'padding': '10px', 'border': '1px solid #dee2e6', 'fontWeight': 'bold', 'textAlign': 'right', 'backgroundColor': '#e9ecef', 'borderTop': '2px solid #000'}) + ])) + + # Cek balancing + is_balanced = (total_debit_neraca == total_kredit_neraca and + total_debit_penyesuaian == total_kredit_penyesuaian and + total_debit_setelah == total_kredit_setelah and + total_debit_laba_rugi + laba_bersih == total_kredit_laba_rugi and + total_debit_posisi + laba_bersih == total_kredit_posisi) + + status_message = html.Div([ + html.H4("✅ NERACA LAJUR BALANCE" if is_balanced else "❌ NERACA LAJUR TIDAK BALANCE", + style={'color': '#28a745' if is_balanced else '#dc3545', 'textAlign': 'center', 'marginTop': '20px'}) + ]) + + return html.Div([ + html.H4(f"NERACA LAJUR - Periode {start_date} s/d {end_date}", style={ + 'textAlign': 'center', + 'color': '#007bff', + 'marginBottom': '20px' + }), + html.Div([ + html.Table( + [header, sub_header] + rows, + style={ + 'width': '100%', + 'borderCollapse': 'collapse', + 'border': '1px solid #dee2e6', + 'fontSize': '11px', + 'overflowX': 'auto' + } + ) + ], style={'overflowX': 'auto', 'width': '100%'}), + status_message + ]) + +def calculate_saldo_akun_periode(jurnal_data, start_date, end_date): + """Hitung saldo akun untuk periode tertentu dari data jurnal""" + saldo_akun = {} + + # Filter jurnal berdasarkan tanggal + jurnal_periode = [j for j in jurnal_data if start_date <= j['tanggal'] <= end_date] + + for jurnal in jurnal_periode: + # Debit + akun_debit = jurnal['akun_debit'] + if akun_debit not in saldo_akun: + saldo_akun[akun_debit] = 0 + saldo_akun[akun_debit] += jurnal['jumlah_debit'] + + # Kredit + akun_kredit = jurnal['akun_kredit'] + if akun_kredit not in saldo_akun: + saldo_akun[akun_kredit] = 0 + saldo_akun[akun_kredit] -= jurnal['jumlah_kredit'] + + return saldo_akun + +def format_currency(amount): + """Format currency dengan pemisah ribuan""" + if amount == 0: + return "" + return f"Rp{amount:,.0f}" + +@app.callback( + Output('tabel-laporan-keuangan', 'children'), + Input('btn-generate-laporan-keuangan', 'n_clicks'), + State('tanggal-laporan-keuangan', 'date'), + prevent_initial_call=True +) +def generate_laporan_keuangan_lengkap(n_clicks, tanggal): + if n_clicks and n_clicks > 0: + # Reuse functions from previous callbacks to get data + saldo_akun = {} + + # Process all journals + semua_jurnal = sibal_data.jurnal_umum + sibal_data.jurnal_penyesuaian + + for jurnal in semua_jurnal: + kode_debit = jurnal['kode_akun_debit'] + if kode_debit not in saldo_akun: + saldo_akun[kode_debit] = { + 'debit': 0, 'kredit': 0, 'nama': jurnal['akun_debit'], + 'tipe': 'aktiva' if kode_debit.startswith('1') else 'pasiva' if kode_debit.startswith('2') else 'modal' if kode_debit.startswith('3') else 'pendapatan' if kode_debit.startswith('4') else 'beban' + } + saldo_akun[kode_debit]['debit'] += jurnal['jumlah_debit'] + + kode_kredit = jurnal['kode_akun_kredit'] + if kode_kredit not in saldo_akun: + saldo_akun[kode_kredit] = { + 'debit': 0, 'kredit': 0, 'nama': jurnal['akun_kredit'], + 'tipe': 'aktiva' if kode_kredit.startswith('1') else 'pasiva' if kode_kredit.startswith('2') else 'modal' if kode_kredit.startswith('3') else 'pendapatan' if kode_kredit.startswith('4') else 'beban' + } + saldo_akun[kode_kredit]['kredit'] += jurnal['jumlah_kredit'] + + # Calculate financial ratios and summaries + aktiva_lancar = sum(data['debit'] - data['kredit'] for kode, data in saldo_akun.items() + if data['tipe'] == 'aktiva' and kode in ['1-110', '1-120', '1-130']) # Kas, Persediaan, Perlengkapan + + aktiva_tetap = sum(data['debit'] - data['kredit'] for kode, data in saldo_akun.items() + if data['tipe'] == 'aktiva' and kode in ['1-210', '1-220', '1-230', '1-240']) # Aset tetap + + kewajiban_lancar = sum(data['kredit'] - data['debit'] for kode, data in saldo_akun.items() + if data['tipe'] == 'pasiva' and kode in ['2-110', '2-120']) # Utang + + modal = sum(data['kredit'] - data['debit'] for kode, data in saldo_akun.items() + if data['tipe'] == 'modal') + + pendapatan = sum(data['kredit'] - data['debit'] for kode, data in saldo_akun.items() + if data['tipe'] == 'pendapatan') + + beban = sum(data['debit'] - data['kredit'] for kode, data in saldo_akun.items() + if data['tipe'] == 'beban') + + laba_bersih = pendapatan - beban + + # Financial ratios + rasio_likuiditas = aktiva_lancar / kewajiban_lancar if kewajiban_lancar > 0 else float('inf') + margin_laba = (laba_bersih / pendapatan * 100) if pendapatan > 0 else 0 + roa = (laba_bersih / (aktiva_lancar + aktiva_tetap) * 100) if (aktiva_lancar + aktiva_tetap) > 0 else 0 + + return html.Div([ + html.H4(f"LAPORAN KEUANGAN LENGKAP", + style={'textAlign': 'center', 'marginBottom': '10px', 'color': COLORS['primary']}), + html.H5(f"Periode sampai dengan {tanggal}", + style={'textAlign': 'center', 'marginBottom': '30px', 'color': COLORS['gray_600']}), + + # Laporan Laba Rugi Section + html.Div([ + html.H5("1. LAPORAN LABA RUGI", style={'color': COLORS['primary'], 'borderBottom': f'2px solid {COLORS["primary"]}', 'paddingBottom': '10px'}), + + html.Div([ + html.Div([ + html.P("Pendapatan Usaha", style={'fontWeight': 'bold'}), + html.P(format_rupiah(pendapatan), style={'fontSize': '1.1rem', 'color': COLORS['success']}) + ], style={'display': 'flex', 'justifyContent': 'space-between', 'marginBottom': '10px'}), + + html.Div([ + html.P("Beban Usaha", style={'fontWeight': 'bold'}), + html.P(format_rupiah(beban), style={'fontSize': '1.1rem', 'color': COLORS['error']}) + ], style={'display': 'flex', 'justifyContent': 'space-between', 'marginBottom': '10px'}), + + html.Hr(), + + html.Div([ + html.P("LABA (RUGI) BERSIH", style={'fontWeight': 'bold', 'fontSize': '1.2rem'}), + html.P(format_rupiah(laba_bersih), + style={'fontSize': '1.3rem', 'fontWeight': 'bold', + 'color': COLORS['success'] if laba_bersih >= 0 else COLORS['error']}) + ], style={'display': 'flex', 'justifyContent': 'space-between', 'marginTop': '10px'}) + ], style={'backgroundColor': COLORS['gray_50'], 'padding': '20px', 'borderRadius': '10px', 'marginTop': '15px'}) + ], style={'marginBottom': '30px'}), + + # Neraca Section + html.Div([ + html.H5("2. NERACA", style={'color': COLORS['secondary'], 'borderBottom': f'2px solid {COLORS["secondary"]}', 'paddingBottom': '10px'}), + + html.Div([ + html.Div([ + html.H6("AKTIVA", style={'color': COLORS['primary']}), + + html.Div([ + html.P("Aktiva Lancar", style={'fontWeight': 'bold'}), + html.P(format_rupiah(aktiva_lancar)) + ], style={'display': 'flex', 'justifyContent': 'space-between', 'marginBottom': '5px'}), + + html.Div([ + html.P("Aktiva Tetap", style={'fontWeight': 'bold'}), + html.P(format_rupiah(aktiva_tetap)) + ], style={'display': 'flex', 'justifyContent': 'space-between', 'marginBottom': '5px'}), + + html.Hr(), + + html.Div([ + html.P("TOTAL AKTIVA", style={'fontWeight': 'bold'}), + html.P(format_rupiah(aktiva_lancar + aktiva_tetap), style={'fontWeight': 'bold'}) + ], style={'display': 'flex', 'justifyContent': 'space-between'}) + ], style={'flex': 1, 'padding': '15px', 'backgroundColor': COLORS['accent_mint'], 'borderRadius': '10px', 'marginRight': '10px'}), + + html.Div([ + html.H6("PASIVA & MODAL", style={'color': COLORS['secondary']}), + + html.Div([ + html.P("Kewajiban Lancar", style={'fontWeight': 'bold'}), + html.P(format_rupiah(kewajiban_lancar)) + ], style={'display': 'flex', 'justifyContent': 'space-between', 'marginBottom': '5px'}), + + html.Div([ + html.P("Modal", style={'fontWeight': 'bold'}), + html.P(format_rupiah(modal)) + ], style={'display': 'flex', 'justifyContent': 'space-between', 'marginBottom': '5px'}), + + html.Div([ + html.P("Laba Ditahan", style={'fontWeight': 'bold', 'fontStyle': 'italic'}), + html.P(format_rupiah(laba_bersih), style={'fontStyle': 'italic'}) + ], style={'display': 'flex', 'justifyContent': 'space-between', 'marginBottom': '5px'}), + + html.Hr(), + + html.Div([ + html.P("TOTAL PASIVA & MODAL", style={'fontWeight': 'bold'}), + html.P(format_rupiah(kewajiban_lancar + modal + laba_bersih), style={'fontWeight': 'bold'}) + ], style={'display': 'flex', 'justifyContent': 'space-between'}) + ], style={'flex': 1, 'padding': '15px', 'backgroundColor': COLORS['accent_mint_light'], 'borderRadius': '10px', 'marginLeft': '10px'}) + ], style={'display': 'flex', 'marginTop': '15px'}) + ], style={'marginBottom': '30px'}), + + # Analisis Rasio Keuangan + html.Div([ + html.H5("3. ANALISIS RASIO KEUANGAN", style={'color': COLORS['accent_teal'], 'borderBottom': f'2px solid {COLORS["accent_teal"]}', 'paddingBottom': '10px'}), + + html.Div([ + html.Div([ + html.Div([ + html.H6("Rasio Likuiditas", style={'color': COLORS['primary']}), + html.P(f"{rasio_likuiditas:.2f}", style={'fontSize': '1.5rem', 'fontWeight': 'bold', 'color': COLORS['success'] if rasio_likuiditas >= 1 else COLORS['warning']}), + html.Small("Aktiva Lancar / Kewajiban Lancar", style={'color': COLORS['gray_600']}), + html.Br(), + html.Small("✅ Baik" if rasio_likuiditas >= 1 else "⚠️ Perlu Perhatian", + style={'color': COLORS['success'] if rasio_likuiditas >= 1 else COLORS['warning']}) + ], style={'textAlign': 'center', 'padding': '15px', 'backgroundColor': COLORS['white'], 'borderRadius': '10px', 'boxShadow': '0 2px 10px rgba(0,0,0,0.1)'}) + ], style={'flex': 1, 'margin': '0 10px'}), + + html.Div([ + html.Div([ + html.H6("Margin Laba Bersih", style={'color': COLORS['primary']}), + html.P(f"{margin_laba:.1f}%", style={'fontSize': '1.5rem', 'fontWeight': 'bold', 'color': COLORS['success'] if margin_laba >= 10 else COLORS['warning'] if margin_laba >= 5 else COLORS['error']}), + html.Small("(Laba Bersih / Pendapatan) × 100%", style={'color': COLORS['gray_600']}), + html.Br(), + html.Small("✅ Excellent" if margin_laba >= 15 else "👍 Baik" if margin_laba >= 10 else "⚠️ Cukup" if margin_laba >= 5 else "❌ Perlu Perbaikan", + style={'color': COLORS['success'] if margin_laba >= 15 else COLORS['info'] if margin_laba >= 10 else COLORS['warning'] if margin_laba >= 5 else COLORS['error']}) + ], style={'textAlign': 'center', 'padding': '15px', 'backgroundColor': COLORS['white'], 'borderRadius': '10px', 'boxShadow': '0 2px 10px rgba(0,0,0,0.1)'}) + ], style={'flex': 1, 'margin': '0 10px'}), + + html.Div([ + html.Div([ + html.H6("Return on Assets (ROA)", style={'color': COLORS['primary']}), + html.P(f"{roa:.1f}%", style={'fontSize': '1.5rem', 'fontWeight': 'bold', 'color': COLORS['success'] if roa >= 5 else COLORS['warning'] if roa >= 2 else COLORS['error']}), + html.Small("(Laba Bersih / Total Aset) × 100%", style={'color': COLORS['gray_600']}), + html.Br(), + html.Small("✅ Excellent" if roa >= 8 else "👍 Baik" if roa >= 5 else "⚠️ Cukup" if roa >= 2 else "❌ Perlu Perbaikan", + style={'color': COLORS['success'] if roa >= 8 else COLORS['info'] if roa >= 5 else COLORS['warning'] if roa >= 2 else COLORS['error']}) + ], style={'textAlign': 'center', 'padding': '15px', 'backgroundColor': COLORS['white'], 'borderRadius': '10px', 'boxShadow': '0 2px 10px rgba(0,0,0,0.1)'}) + ], style={'flex': 1, 'margin': '0 10px'}) + ], style={'display': 'flex', 'marginTop': '15px'}) + ], style={'marginBottom': '30px'}), + + # Ringkasan Eksekutif + html.Div([ + html.H5("4. RINGKASAN EKSEKUTIF", style={'color': COLORS['accent_lavender'], 'borderBottom': f'2px solid {COLORS["accent_lavender"]}', 'paddingBottom': '10px'}), + + html.Div([ + html.P("🔍 **Tinjauan Kinerja:**", style={'fontWeight': 'bold'}), + html.Ul([ + html.Li(f"Perusahaan {'mencapai laba' if laba_bersih >= 0 else 'mengalami rugi'} sebesar {format_rupiah(abs(laba_bersih))}"), + html.Li(f"Margin laba bersih sebesar {margin_laba:.1f}% {'di atas rata-rata industri' if margin_laba >= 15 else 'sesuai ekspektasi' if margin_laba >= 8 else 'perlu peningkatan'}"), + html.Li(f"Total aset perusahaan senilai {format_rupiah(aktiva_lancar + aktiva_tetap)}"), + ]), + + html.P("💡 **Rekomendasi:**", style={'fontWeight': 'bold', 'marginTop': '15px'}), + html.Ul([ + html.Li("Pertahankan pertumbuhan pendapatan dengan strategi pemasaran yang efektif"), + html.Li("Optimalkan pengelolaan persediaan untuk meningkatkan likuiditas"), + html.Li("Monitor beban operasional untuk efisiensi yang lebih baik"), + ]) if laba_bersih >= 0 else html.Ul([ + html.Li("Lakukan review terhadap struktur biaya operasional"), + html.Li("Tingkatkan efisiensi dalam pengelolaan persediaan"), + html.Li("Evaluasi strategi penetapan harga jual"), + ]) + ], style={'backgroundColor': COLORS['gray_50'], 'padding': '20px', 'borderRadius': '10px', 'marginTop': '15px'}) + ]) + ]) + + return html.P("Klik 'Generate Laporan Keuangan Lengkap' untuk melihat laporan", style={'color': '#6c757d'}) + + +if __name__ == '__main__': + print("🚀 Starting SIBAL Application...") + print("📊 Debug mode: ON") + print("🌐 Server will be available at: http://127.0.0.1:8051") + print("⏳ Please wait for server to start...") + + try: + app.run(debug=True, port=8051) + print("✅ Server started successfully!") + except Exception as e: + print(f"❌ Error starting server: {e}") + print("💡 Try changing port to 8052") diff --git a/sibal_data.json b/sibal_data.json new file mode 100644 index 0000000..27799bc --- /dev/null +++ b/sibal_data.json @@ -0,0 +1,301 @@ +{ + "transaksi_pemasukan": [ + { + "tanggal": "2025-11-18", + "jenis": "penjualan_ikan", + "jumlah": 60000, + "quantity": 5, + "hpp": 10000, + "keterangan": "bawal", + "ref": "TR", + "tipe": "pemasukan" + } + ], + "transaksi_pengeluaran": [ + { + "tanggal": "2025-11-18", + "jenis": "pembelian_persediaan", + "kode_barang": "IK001", + "jenis_aset": "", + "jumlah": 30000, + "quantity": 200, + "metode_bayar": "tunai", + "supplier": "", + "keterangan": "beli", + "ref": "tr", + "tipe": "pengeluaran" + }, + { + "tanggal": "2025-11-18", + "jenis": "pembelian_aset", + "kode_barang": "", + "jenis_aset": "tanah", + "jumlah": 2100000000, + "quantity": 0, + "metode_bayar": "tunai", + "supplier": "", + "keterangan": "Pengeluaran - pembelian_aset", + "ref": "TR", + "tipe": "pengeluaran" + }, + { + "tanggal": "2025-11-18", + "jenis": "pembelian_persediaan", + "kode_barang": "IK001", + "jumlah": 300000, + "quantity": 100, + "metode_bayar": "kredit", + "supplier": "harni", + "keterangan": "beli", + "ref": "TR", + "tipe": "pengeluaran" + } + ], + "jurnal_umum": [ + { + "tanggal": "2025-11-18", + "keterangan": "bawal", + "ref": "TR", + "akun_debit": "Kas", + "jumlah_debit": 60000, + "akun_kredit": "Pendapatan", + "jumlah_kredit": 60000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "HPP - bawal", + "ref": "TR-HPP", + "akun_debit": "HPP", + "jumlah_debit": 50000, + "akun_kredit": "Persediaan", + "jumlah_kredit": 50000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "beli", + "ref": "tr", + "akun_debit": "Persediaan", + "jumlah_debit": 30000, + "akun_kredit": "Kas", + "jumlah_kredit": 30000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "beli", + "ref": "tr", + "akun_debit": "Persediaan", + "jumlah_debit": 30000, + "akun_kredit": "Kas", + "jumlah_kredit": 30000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "Pengeluaran - pembelian_aset", + "ref": "TR", + "akun_debit": "Tanah", + "jumlah_debit": 2100000000, + "akun_kredit": "Kas", + "jumlah_kredit": 2100000000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "Pencatatan Kendaraan", + "ref": "AST-KENDARAAN", + "akun_debit": "kendaraan", + "jumlah_debit": 150000000, + "akun_kredit": "Kas", + "jumlah_kredit": 150000000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "beli", + "ref": "tr", + "akun_debit": "Persediaan", + "jumlah_debit": 30000, + "akun_kredit": "Kas", + "jumlah_kredit": 30000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "Pengeluaran - pembelian_aset", + "ref": "TR", + "akun_debit": "Beban Lainnya", + "jumlah_debit": 2100000000, + "akun_kredit": "Kas", + "jumlah_kredit": 2100000000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "beli", + "ref": "TR", + "akun_debit": "Persediaan", + "jumlah_debit": 300000, + "akun_kredit": "Utang", + "jumlah_kredit": 300000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "beli", + "ref": "tr", + "akun_debit": "Persediaan", + "jumlah_debit": 30000, + "akun_kredit": "Kas", + "jumlah_kredit": 30000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "Pengeluaran - pembelian_aset", + "ref": "TR", + "akun_debit": "Tanah", + "jumlah_debit": 2100000000, + "akun_kredit": "Kas", + "jumlah_kredit": 2100000000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "beli", + "ref": "TR", + "akun_debit": "Persediaan", + "jumlah_debit": 300000, + "akun_kredit": "Utang", + "jumlah_kredit": 300000 + }, + { + "tanggal": "2025-11-18", + "keterangan": "Akumulasi Utang Supplier", + "ref": "UTG-001", + "akun_debit": "Persediaan", + "jumlah_debit": 300000, + "akun_kredit": "Utang", + "jumlah_kredit": 300000 + } + ], + "jurnal_penyesuaian": [ + { + "tanggal": "2025-11-18", + "keterangan": "Penyusutan kendaraan tahun ke-3", + "ref": "PST-KENDARAAN-3", + "akun_debit": "Beban Penyusutan Kendaraan", + "jumlah_debit": 30000000.0, + "akun_kredit": "Akumulasi Penyusutan Kendaraan", + "jumlah_kredit": 30000000.0 + }, + { + "tanggal": "2025-11-18", + "keterangan": "Penyusutan kendaraan tahun ke-3", + "ref": "PST-KENDARAAN-3", + "akun_debit": "Beban Penyusutan Kendaraan", + "jumlah_debit": 30000000.0, + "akun_kredit": "Akumulasi Penyusutan Kendaraan", + "jumlah_kredit": 30000000.0 + } + ], + "buku_besar": { + "kas": [], + "pendapatan": [], + "persediaan": [], + "utang": [], + "hpp": [], + "pendapatan_tiket": [], + "tanah": [], + "bangunan_gazebo": [], + "kendaraan": [], + "peralatan": [], + "akumulasi_penyusutan_bangunan": [], + "akumulasi_penyusutan_kendaraan": [], + "akumulasi_penyusutan_peralatan": [], + "beban_gaji": [], + "beban_listrik": [], + "beban_penyusutan_bangunan": [], + "beban_penyusutan_kendaraan": [], + "beban_penyusutan_peralatan": [], + "beban_lainnya": [] + }, + "buku_besar_pembantu": { + "harni": [ + { + "tanggal": "2025-11-18", + "keterangan": "pembelian_persediaan - beli", + "debit": 0, + "kredit": 300000, + "saldo": 300000 + } + ] + }, + "kartu_persediaan": [ + { + "tanggal": "2025-11-18", + "kode_barang": "IK001", + "nama_barang": "Ikan Bawal Segar", + "masuk_qty": 0, + "masuk_harga": 0, + "masuk_total": 0, + "keluar_qty": 5, + "keluar_harga": 10000, + "keluar_total": 50000, + "saldo_qty": -5, + "saldo_harga": 10000, + "saldo_total": -50000, + "keterangan": "Penjualan - bawal" + }, + { + "tanggal": "2025-11-18", + "kode_barang": "IK001", + "nama_barang": "Ikan Bawal Segar", + "masuk_qty": 200, + "masuk_harga": 150.0, + "masuk_total": 30000, + "keluar_qty": 0, + "keluar_harga": 0, + "keluar_total": 0, + "saldo_qty": 200, + "saldo_harga": 150.0, + "saldo_total": 30000, + "keterangan": "Pembelian - beli" + }, + { + "tanggal": "2025-11-18", + "kode_barang": "IK001", + "nama_barang": "Ikan Bawal Segar", + "masuk_qty": 100, + "masuk_harga": 3000.0, + "masuk_total": 300000, + "keluar_qty": 0, + "keluar_harga": 0, + "keluar_total": 0, + "saldo_qty": 100, + "saldo_harga": 3000.0, + "saldo_total": 300000, + "keterangan": "Pembelian - beli" + } + ], + "suppliers": [ + "harni" + ], + "aset_tetap": { + "tanah": { + "nilai_awal": 2100000000, + "penyusutan": 0, + "masa_manfaat": 0, + "tahun_pembelian": 2025 + }, + "bangunan_gazebo": { + "nilai_awal": 0, + "penyusutan": 0, + "masa_manfaat": 10, + "tahun_pembelian": 2025 + }, + "kendaraan": { + "nilai_awal": 150000000, + "penyusutan": 60000000.0, + "masa_manfaat": 5, + "tahun_pembelian": 2021 + }, + "peralatan": { + "nilai_awal": 0, + "penyusutan": 0, + "masa_manfaat": 3, + "tahun_pembelian": 2025 + } + } +} \ No newline at end of file diff --git a/text b/text new file mode 100644 index 0000000..d1edaa4 --- /dev/null +++ b/text @@ -0,0 +1,8 @@ +cd 'C:\Users\ilham\Downloads\Projek-SIBAL-main\Projek-SIBAL-main' +python -m venv .venv +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -Force +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +pip install authlib +pip freeze > requirements.txt +python sibal.py \ No newline at end of file