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 import re 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) def delete_old_transactions(table_name: str, older_than_days: int | None = None) -> dict: """Delete historical rows from a Supabase table. - If `older_than_days` is an int, delete rows where `tanggal` < (today - days). - If `older_than_days` is None, delete ALL rows in the table (best-effort). Returns a dict with result information. """ try: if older_than_days is None: # Best-effort: fetch ids then delete by ids to avoid delete-without-filter issues resp = supabase_client.table(table_name).select('id').execute() ids = [r.get('id') for r in (resp.data or []) if r.get('id') is not None] if not ids: return {'deleted': 0, 'ids_deleted': []} del_resp = supabase_client.table(table_name).delete().in_('id', ids).execute() return {'deleted': len(ids), 'ids_deleted': ids, 'raw': del_resp} else: cutoff = (datetime.now().date() - timedelta(days=older_than_days)).isoformat() # Try deleting by tanggal < cutoff del_resp = supabase_client.table(table_name).delete().lt('tanggal', cutoff).execute() # supabase returns deleted rows in del_resp.data when succesful deleted = len(del_resp.data) if getattr(del_resp, 'data', None) else 0 return {'deleted': deleted, 'cutoff': cutoff, 'raw': del_resp} except Exception as e: print(f"[CLEANUP] Error deleting from {table_name}: {e}") return {'error': str(e)} @server.route('/admin/cleanup-transactions') def admin_cleanup_transactions(): """HTTP helper to cleanup transactions. Query params: - table: 'pemasukan'|'pengeluaran'|'both' (default: both) - days: integer number of days to keep (delete older than days), or 'all' to delete everything Example: /admin/cleanup-transactions?table=both&days=365 """ try: table_param = request.args.get('table', 'both') days_param = request.args.get('days', None) if days_param is None: return ("Usage: provide ?table={pemasukan|pengeluaran|both}&days={N|all}", 400) if days_param == 'all': days = None else: try: days = int(days_param) except Exception: return ("Invalid days parameter", 400) results = {} targets = [] if table_param in ('pemasukan', 'both'): targets.append('transaksi_pemasukan') if table_param in ('pengeluaran', 'both'): targets.append('transaksi_pengeluaran') for t in targets: results[t] = delete_old_transactions(t, days) return results except Exception as e: return {'error': str(e)} # ===== 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 # Make a shallow copy so we can mutate for retry attempts payload = dict(data) attempts = 0 while attempts < 3: attempts += 1 try: response = self.client.table(table_name).insert(payload).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: err = str(e) print(f"âš ī¸ Insert attempt {attempts} failed for {table_name}: {err}") # Handle PostgREST schema cache error where a column is missing # message example: "Could not find the 'user_id' column of 'transaksi_pemasukan' in the schema cache" m = re.search(r"Could not find the '([a-zA-Z0-9_]+)' column", err) if m: missing_col = m.group(1) if missing_col in payload: print(f"â„šī¸ Removing unknown column '{missing_col}' and retrying insert to {table_name}") payload.pop(missing_col, None) continue # If not a schema-missing issue or column not in payload, stop retrying print(f"❌ Error inserting to {table_name}: {err}") 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-balance-scale"), " Neraca Saldo Setelah Penyesuaian" ], href="/neraca-saldo-penyesuaian", className="sub-nav-link"), dcc.Link([ html.I(className="fas fa-chart-line"), " Laporan Posisi Keuangan" ], href="/neraca-setelah-penyesuaian", className="sub-nav-link"), dcc.Link([ html.I(className="fas fa-clipboard-list"), " Jurnal Penutup" ], href="/jurnal-penutup", className="sub-nav-link"), dcc.Link([ html.I(className="fas fa-file-invoice"), " Neraca Setelah Penutupan" ], href="/neraca-setelah-penutupan", 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 (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 (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'} ), # Single-row input form (masuk / keluar) - user requested html.Div([ html.Div([ html.Div([ html.Label('Tanggal', className='form-label'), dcc.DatePickerSingle(id='tanggal-persediaan', date=datetime.now().date(), display_format='YYYY-MM-DD', className='form-control') ], className='form-group', style={'width': '18%', 'display': 'inline-block', 'marginRight': '10px'}), html.Div([ html.Label('Barang', className='form-label'), dcc.Dropdown(id='dropdown-kode-barang', options=[{'label': f"{b['kode']} - {b['nama']}", 'value': b['kode']} for b in sibal_data.master_persediaan], placeholder='Pilih Barang', className='form-control') ], className='form-group', style={'width': '30%', 'display': 'inline-block', 'marginRight': '10px'}), html.Div([ html.Label('Masuk (Qty)', className='form-label'), dcc.Input(id='input-masuk-qty', type='number', value=0, min=0, className='form-control') ], className='form-group', style={'width': '12%', 'display': 'inline-block', 'marginRight': '10px'}), html.Div([ html.Label('Masuk (Harga)', className='form-label'), dcc.Input(id='input-masuk-harga', type='number', value=0, min=0, className='form-control') ], className='form-group', style={'width': '15%', 'display': 'inline-block', 'marginRight': '10px'}), html.Div([ html.Label('Keluar (Qty)', className='form-label'), dcc.Input(id='input-keluar-qty', type='number', value=0, min=0, className='form-control') ], className='form-group', style={'width': '12%', 'display': 'inline-block', 'marginRight': '10px'}), html.Div([ html.Label('Keterangan', className='form-label'), dcc.Input(id='input-keterangan-persediaan', type='text', value='', className='form-control') ], className='form-group', style={'width': '100%', 'display': 'block', 'marginTop': '10px'}), html.Button('💾 Simpan Transaksi', id='btn-simpan-kartu-persediaan', className='btn btn-success', style={'marginTop': '10px'}) ], className='form-row') ], style={'marginBottom': '18px'}), 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("Laporan posisi keuangan", 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 Laporan Posisi Keuangan", 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 neraca_saldo_penyesuaian_layout(): return html.Div([ html.Div([ html.Div([ html.Div([html.I(className='fas fa-balance-scale')], className='card-icon'), html.H1('Neraca Saldo 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-saldo-penyesuaian', date=datetime.now().date(), display_format='YYYY-MM-DD', className='form-control')], className='form-group'), html.Div([html.Button('📊 Generate Neraca Saldo Setelah Penyesuaian', id='btn-generate-neraca-saldo-penyesuaian', className='btn btn-primary'), html.Button('💾 Simpan', id='btn-save-neraca-saldo-penyesuaian', className='btn btn-secondary', style={'marginLeft':'10px'})], style={'marginBottom':'12px'}), html.Div(id='tabel-neraca-saldo-penyesuaian', className='table-container'), html.Div(id='save-neraca-saldo-penyesuaian-status', style={'marginTop':'12px'}) ], className='card-content') ], className='glass-card') ], className='main-container') def jurnal_penutup_layout(): return html.Div([ html.Div([ html.Div([ html.Div([html.I(className="fas fa-clipboard-list")], className="card-icon"), html.H1("Jurnal Penutup", className="card-title") ], className="card-header"), html.Div([ html.Div([ html.Label("Tanggal Jurnal Penutup", className="form-label"), dcc.DatePickerSingle( id='tanggal-jurnal-penutup', date=datetime.now().date(), display_format='YYYY-MM-DD', className='form-control' ) ], className='form-group'), html.Button("📌 Generate Jurnal Penutup", id='btn-generate-jurnal-penutup', className='btn btn-primary'), html.Div(id='tabel-jurnal-penutup', className='table-container') ], className='card-content') ], className='glass-card') ], className='main-container') def neraca_setelah_penutupan_layout(): return html.Div([ html.Div([ html.Div([html.Div([html.I(className='fas fa-file-invoice')], className='card-icon'), html.H1('Neraca Setelah Penutupan', className='card-title')], className='card-header'), html.Div([ html.Div([html.Label('Pilih Tanggal Neraca Setelah Penutupan:', className='form-label'), dcc.DatePickerSingle(id='tanggal-neraca-penutupan', date=datetime.now().date(), display_format='YYYY-MM-DD', className='form-control')], className='form-group'), html.Div([html.Button('📊 Generate Neraca Setelah Penutupan', id='btn-generate-neraca-penutupan', className='btn btn-primary'), html.Button('💾 Simpan ke Supabase', id='btn-save-neraca-penutupan', className='btn btn-secondary', style={'marginLeft':'10px'})], style={'marginBottom':'12px'}), html.Div(id='tabel-neraca-penutupan', className='table-container'), html.Div(id='save-neraca-penutupan-status', style={'marginTop':'12px'}) ], 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', '/jurnal-penutup', '/neraca-setelah-penutupan', '/neraca-saldo-penyesuaian' ] 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 == '/jurnal-penutup': content = jurnal_penutup_layout() elif pathname == '/neraca-setelah-penutupan': content = neraca_setelah_penutupan_layout() elif pathname == '/neraca-saldo-penyesuaian': content = neraca_saldo_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 = [] # Each checklist returns a list of selected values. We encode option values as # 'id:' when item has been saved to DB, or 'idx:' for in-memory items. for i, checked in enumerate(checklists): if checked: val = checked[0] if isinstance(val, str) and val.startswith('id:'): tid = val.split(':', 1)[1] for idx_t, t in enumerate(transaksi_hari_ini): if str(t.get('id')) == str(tid): indices_to_delete.append(idx_t) break elif isinstance(val, str) and val.startswith('idx:'): try: idx_val = int(val.split(':', 1)[1]) if idx_val < len(transaksi_hari_ini): indices_to_delete.append(idx_val) except Exception: pass 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] # If saved to DB, attempt DB delete try: if 'id' in transaksi_to_delete and transaksi_to_delete.get('id') and supabase_client: supabase_client.table('transaksi_pemasukan').delete().eq('id', transaksi_to_delete.get('id')).execute() except Exception as e: print(f"[DELETE] Failed to delete pemasukan id {transaksi_to_delete.get('id')}: {e}") 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 = [] # Parse checklist selected values encoded as 'id:' or 'idx:' for i, checked in enumerate(checklists): if checked: val = checked[0] if isinstance(val, str) and val.startswith('id:'): tid = val.split(':', 1)[1] for idx_t, t in enumerate(transaksi_hari_ini): if str(t.get('id')) == str(tid): indices_to_delete.append(idx_t) break elif isinstance(val, str) and val.startswith('idx:'): try: idx_val = int(val.split(':', 1)[1]) if idx_val < len(transaksi_hari_ini): indices_to_delete.append(idx_val) except Exception: pass 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] # If saved to DB, attempt DB delete try: if 'id' in transaksi_to_delete and transaksi_to_delete.get('id') and supabase_client: supabase_client.table('transaksi_pengeluaran').delete().eq('id', transaksi_to_delete.get('id')).execute() except Exception as e: print(f"[DELETE] Failed to delete pengeluaran id {transaksi_to_delete.get('id')}: {e}") 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) # encode option value to indicate DB id or index if trans.get('id'): opt_value = f"id:{trans.get('id')}" else: opt_value = f"idx:{i}" items_pemasukan.append(html.Div([ dcc.Checklist( id={'type': 'pemasukan-check', 'index': i}, options=[{'label': '', 'value': opt_value}], 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') # encode option value to indicate DB id or index if trans.get('id'): opt_value = f"id:{trans.get('id')}" else: opt_value = f"idx:{i}" items_pengeluaran.append(html.Div([ dcc.Checklist( id={'type': 'pengeluaran-check', 'index': i}, options=[{'label': '', 'value': opt_value}], 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('tabel-kartu-persediaan', 'children'), Input('btn-simpan-kartu-persediaan', 'n_clicks'), [State('dropdown-kode-barang', 'value'), State('tanggal-persediaan', 'date'), State('input-masuk-qty', 'value'), State('input-masuk-harga', 'value'), State('input-keluar-qty', 'value'), State('input-keterangan-persediaan', 'value')], prevent_initial_call=True, allow_duplicate=True ) def save_kartu_persediaan_row(n_clicks, kode_barang, tanggal, masuk_qty, masuk_harga, keluar_qty, keterangan): """Simpan satu baris transaksi kartu persediaan (masuk atau keluar).""" ctx = dash.callback_context if not ctx.triggered: return dash.no_update # Normalize inputs try: masuk_qty = int(masuk_qty) if masuk_qty else 0 except Exception: masuk_qty = 0 try: masuk_harga = float(masuk_harga) if masuk_harga else 0 except Exception: masuk_harga = 0 try: keluar_qty = int(keluar_qty) if keluar_qty else 0 except Exception: keluar_qty = 0 if not kode_barang: return html.Div([html.P('âš ī¸ Pilih barang terlebih dahulu', style={'color': '#d9534f'})]) if masuk_qty <= 0 and keluar_qty <= 0: return html.Div([html.P('âš ī¸ Masukkan qty masuk atau keluar (minimal salah satu > 0)', style={'color': '#d9534f'})]) # Use existing helpers to update in-memory kartu persediaan result_msg = None if masuk_qty > 0: success = update_persediaan_pembelian(kode_barang, masuk_qty, masuk_harga, tanggal, keterangan) if success: result_msg = html.Div([html.P('✅ Transaksi pembelian disimpan', style={'color': '#28a745'})]) else: result_msg = html.Div([html.P('❌ Gagal menyimpan pembelian', style={'color': '#d9534f'})]) if keluar_qty > 0: success, hpp = update_persediaan_penjualan(kode_barang, keluar_qty, tanggal, keterangan) if success: result_msg = html.Div([html.P('✅ Transaksi penjualan disimpan', style={'color': '#28a745'})]) else: result_msg = html.Div([html.P('❌ Gagal menyimpan penjualan (cek stok)', style={'color': '#d9534f'})]) # Persist data to Supabase try: sibal_data.save_all_data() except Exception as e: print(f"❌ Error saat menyimpan ke Supabase: {e}") # Return refreshed table by calling the update function directly try: return update_kartu_persediaan(None) except Exception as e: print(f"❌ Error saat merefresh tampilan kartu persediaan: {e}") return result_msg if result_msg is not None else html.Div([html.P('Operasi selesai')]) @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"Laporan Posisi Keuangan 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 Laporan Posisi Keuangan' 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 @app.callback( Output('tabel-jurnal-penutup', 'children'), Input('btn-generate-jurnal-penutup', 'n_clicks'), State('tanggal-jurnal-penutup', 'date'), prevent_initial_call=True ) def generate_jurnal_penutup(n_clicks, tanggal): if not (n_clicks and n_clicks > 0): return html.P("Klik 'Generate Jurnal Penutup' untuk membuat jurnal penutup", style={'color': '#6c757d'}) # Hitung saldo akun dari jurnal umum + jurnal penyesuaian saldo_akun = {} 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']} 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']} saldo_akun[kode_kredit]['kredit'] += jurnal['jumlah_kredit'] # Identifikasi akun nominal (pendapatan & beban) jurnal_penutup_entries = [] total_pendapatan = 0 total_beban = 0 for kode, data in sorted(saldo_akun.items()): tipe = ('pendapatan' if kode.startswith('4') else 'beban' if kode.startswith('5') or kode.startswith('6') else None) saldo = (data['kredit'] - data['debit']) if tipe == 'pendapatan' else (data['debit'] - data['kredit']) if tipe == 'beban' else 0 if tipe == 'pendapatan' and saldo != 0: # Tutup pendapatan: Debit akun pendapatan, Kredit Ikhtisar Laba Rugi jurnal_penutup_entries.append({ 'tanggal': tanggal, 'keterangan': f"Penutupan akun pendapatan {data['nama']}", 'akun_debit': data['nama'], 'debit': saldo, 'akun_kredit': 'Ikhtisar Laba Rugi', 'kredit': saldo }) total_pendapatan += saldo elif tipe == 'beban' and saldo != 0: # Tutup beban: Debit Ikhtisar Laba Rugi, Kredit akun beban jurnal_penutup_entries.append({ 'tanggal': tanggal, 'keterangan': f"Penutupan akun beban {data['nama']}", 'akun_debit': 'Ikhtisar Laba Rugi', 'debit': saldo, 'akun_kredit': data['nama'], 'kredit': saldo }) total_beban += saldo laba_rugi = total_pendapatan - total_beban # Tutup Ikhtisar Laba Rugi ke Modal if laba_rugi != 0: if laba_rugi > 0: # Laba: Debit Ikhtisar, Kredit Modal jurnal_penutup_entries.append({ 'tanggal': tanggal, 'keterangan': 'Penutupan Ikhtisar Laba Rugi (Laba)', 'akun_debit': 'Ikhtisar Laba Rugi', 'debit': laba_rugi, 'akun_kredit': KODE_AKUN['modal']['nama'], 'kredit': laba_rugi }) else: # Rugi: Debit Modal, Kredit Ikhtisar jurnal_penutup_entries.append({ 'tanggal': tanggal, 'keterangan': 'Penutupan Ikhtisar Laba Rugi (Rugi)', 'akun_debit': KODE_AKUN['modal']['nama'], 'debit': abs(laba_rugi), 'akun_kredit': 'Ikhtisar Laba Rugi', 'kredit': abs(laba_rugi) }) # Render tabel jurnal penutup if jurnal_penutup_entries: 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 j in jurnal_penutup_entries: rows.append(html.Tr([html.Td(j['tanggal']), html.Td(j['keterangan']), html.Td(j['akun_debit']), html.Td(format_rupiah(j['debit'])), html.Td(j['akun_kredit']), html.Td(format_rupiah(j['kredit']))])) # Also persist jurnal penutup entries to Supabase table 'jurnal_penutup' (delete existing for same tanggal+user) insert_count = 0 insert_errors = [] try: if sibal_data and getattr(sibal_data, 'active_user_id', None) is not None: try: supabase_client.table('jurnal_penutup').delete().eq('tanggal', str(tanggal)).eq('user_id', sibal_data.active_user_id).execute() except Exception: # ignore delete errors but continue to attempt inserts pass # Build name -> code map from existing data to supply kode_akun_* fields code_map = {} try: all_journals = sibal_data.jurnal_umum + sibal_data.jurnal_penyesuaian for jj in all_journals: name_debit = jj.get('akun_debit') code_debit = jj.get('kode_akun_debit') if name_debit and code_debit: code_map.setdefault(name_debit, code_debit) name_kredit = jj.get('akun_kredit') code_kredit = jj.get('kode_akun_kredit') if name_kredit and code_kredit: code_map.setdefault(name_kredit, code_kredit) except Exception: pass for j in jurnal_penutup_entries: akun_debet_name = j.get('akun_debit','') akun_kredit_name = j.get('akun_kredit','') kode_debet = code_map.get(akun_debet_name) kode_kredit = code_map.get(akun_kredit_name) # try known KODE_AKUN mapping for modal or ikhtisar if available try: if not kode_debet and akun_debet_name and 'modal' in globals() or 'KODE_AKUN' in globals(): kode_debet = KODE_AKUN.get(akun_debet_name, {}).get('kode') if isinstance(KODE_AKUN, dict) else None except Exception: pass try: if not kode_kredit and akun_kredit_name and 'KODE_AKUN' in globals(): kode_kredit = KODE_AKUN.get(akun_kredit_name, {}).get('kode') if isinstance(KODE_AKUN, dict) else None except Exception: pass # fallback to using the account name as code (to satisfy NOT NULL constraints) if not kode_debet: kode_debet = str(akun_debet_name) if not kode_kredit: kode_kredit = str(akun_kredit_name) payload = { 'tanggal': str(j.get('tanggal')), 'keterangan': j.get('keterangan',''), 'akun_debit': akun_debet_name, 'kode_akun_debit': kode_debet, 'jumlah_debit': float(j.get('debit',0) or 0), 'akun_kredit': akun_kredit_name, 'kode_akun_kredit': kode_kredit, 'jumlah_kredit': float(j.get('kredit',0) or 0), 'user_id': sibal_data.active_user_id } try: resp = supabase_client.table('jurnal_penutup').insert(payload).execute() if getattr(resp, 'status_code', 0) in (200,201) or getattr(resp, 'data', None): insert_count += 1 else: insert_errors.append(str(payload.get('akun_debit'))) except Exception as e: insert_errors.append(f"{payload.get('akun_debit')}:{e}") except Exception: insert_errors.append('unknown') status_el = None if insert_count > 0: status_msg = f"✅ Tersimpan {insert_count} baris jurnal penutup ke tabel 'jurnal_penutup'" if insert_errors: status_msg += f" — gagal: {', '.join(insert_errors)}" status_el = html.Div(status_msg, style={'color': COLORS['success'], 'marginTop': '10px'}) elif insert_errors: status_el = html.Div(f"âš ī¸ Gagal menyimpan jurnal penutup: {', '.join(insert_errors)}", style={'color': COLORS['warning'], 'marginTop': '10px'}) content = [html.Table([header] + rows, className='modern-table')] if status_el: content.append(status_el) return html.Div(content) return html.P('Tidak ada akun nominal yang perlu ditutup', style={'color': '#6c757d'}) @app.callback( Output('tabel-neraca-penutupan', 'children'), Input('btn-generate-neraca-penutupan', 'n_clicks'), State('tanggal-neraca-penutupan', 'date'), prevent_initial_call=True ) def generate_neraca_setelah_penutupan(n_clicks, tanggal): if not (n_clicks and n_clicks > 0): return html.P("Klik 'Generate Neraca Setelah Penutupan' untuk melihat neraca", style={'color': '#6c757d'}) # Ambil saldo dari jurnal umum + penyesuaian saldo_akun = {} semua_jurnal = sibal_data.jurnal_umum + sibal_data.jurnal_penyesuaian for jurnal in semua_jurnal: kode_debit = jurnal.get('kode_akun_debit') kode_kredit = jurnal.get('kode_akun_kredit') if kode_debit: if kode_debit not in saldo_akun: saldo_akun[kode_debit] = {'debit': 0.0, 'kredit': 0.0, 'nama': jurnal.get('akun_debit','')} saldo_akun[kode_debit]['debit'] += float(jurnal.get('jumlah_debit', 0) or 0) if kode_kredit: if kode_kredit not in saldo_akun: saldo_akun[kode_kredit] = {'debit': 0.0, 'kredit': 0.0, 'nama': jurnal.get('akun_kredit','')} saldo_akun[kode_kredit]['kredit'] += float(jurnal.get('jumlah_kredit', 0) or 0) # Hitung laba rugi dari akun pendapatan/beban pendapatan = {k: v for k, v in saldo_akun.items() if str(k).startswith('4')} beban = {k: v for k, v in saldo_akun.items() if str(k).startswith('5') or str(k).startswith('6')} total_pendapatan = sum((v['kredit'] - v['debit']) for v in pendapatan.values()) total_beban = sum((v['debit'] - v['kredit']) for v in beban.values()) laba_rugi = total_pendapatan - total_beban # Siapkan baris neraca setelah penutupan dalam format Debit/Kredit rows = [] total_debit = 0.0 total_kredit = 0.0 # Masukkan semua akun non-nominal (exclude pendapatan & beban) for kode, data in sorted(saldo_akun.items()): kode_str = str(kode) tipe = 'aktiva' if kode_str.startswith('1') else 'pasiva' if kode_str.startswith('2') else 'modal' if kode_str.startswith('3') else 'pendapatan' if kode_str.startswith('4') else 'beban' if kode_str.startswith('5') or kode_str.startswith('6') else 'lainnya' if tipe in ('pendapatan', 'beban'): continue # aktiva shown as debit = debit - kredit ; pasiva/modal shown as kredit = kredit - debit debit_amt = 0.0 kredit_amt = 0.0 if tipe == 'aktiva': saldo = data['debit'] - data['kredit'] if abs(saldo) < 0.01: continue debit_amt = round(saldo,2) if saldo > 0 else 0.0 kredit_amt = round(abs(saldo),2) if saldo < 0 else 0.0 else: saldo = data['kredit'] - data['debit'] if abs(saldo) < 0.01: continue kredit_amt = round(saldo,2) if saldo > 0 else 0.0 debit_amt = round(abs(saldo),2) if saldo < 0 else 0.0 rows.append(html.Tr([html.Td(kode), html.Td(data.get('nama','')), html.Td(format_rupiah(debit_amt) if debit_amt else ''), html.Td(format_rupiah(kredit_amt) if kredit_amt else '')])) total_debit += debit_amt total_kredit += kredit_amt # Tambahkan laba/rugi ke modal (jika ada) if abs(laba_rugi) >= 0.01: # find modal kode if available modal_kode = KODE_AKUN.get('modal', {}).get('kode') modal_nama = KODE_AKUN.get('modal', {}).get('nama') if modal_kode: # laba (positif) increases modal kredit, rugi increases debit if laba_rugi > 0: rows.append(html.Tr([html.Td(modal_kode), html.Td(modal_nama or 'Modal'), html.Td(''), html.Td(format_rupiah(round(laba_rugi,2)))])) total_kredit += round(laba_rugi,2) else: rows.append(html.Tr([html.Td(modal_kode), html.Td(modal_nama or 'Modal'), html.Td(format_rupiah(round(abs(laba_rugi),2))), html.Td('')])) total_debit += round(abs(laba_rugi),2) header = html.Tr([html.Th('Kode'), html.Th('Nama Akun'), html.Th('Debit'), html.Th('Kredit')]) if rows: rows.append(html.Tr([html.Td('', 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'})])) balans = abs(total_debit - total_kredit) < 0.01 status = html.P('✅ Neraca Setelah Penutupan Seimbang' if balans else '❌ Neraca Setelah Penutupan Tidak Seimbang', style={'textAlign': 'center', 'fontWeight': 'bold', 'color': COLORS['success'] if balans else COLORS['error']}) return html.Div([html.H4(f'Neraca Setelah Penutupan per {tanggal}', style={'textAlign': 'center', 'marginBottom': '20px', 'color': COLORS['primary']}), html.Table([header] + rows, className='modern-table', style={'width': '100%'}), status]) return html.P('Tidak ada akun non-nominal untuk ditampilkan setelah penutupan', style={'color': '#6c757d'}) @app.callback( Output('save-neraca-penutupan-status', 'children'), Input('btn-save-neraca-penutupan', 'n_clicks'), State('tanggal-neraca-penutupan', 'date'), prevent_initial_call=True ) def save_neraca_setelah_penutupan(n_clicks, tanggal): if not (n_clicks and n_clicks > 0): return '' # Recompute balances (same logic as generate_neraca_setelah_penutupan) saldo_akun = {} 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.0, 'kredit': 0.0, 'nama': jurnal['akun_debit']} saldo_akun[kode_debit]['debit'] += float(jurnal.get('jumlah_debit', 0) or 0) kode_kredit = jurnal['kode_akun_kredit'] if kode_kredit not in saldo_akun: saldo_akun[kode_kredit] = {'debit': 0.0, 'kredit': 0.0, 'nama': jurnal['akun_kredit']} saldo_akun[kode_kredit]['kredit'] += float(jurnal.get('jumlah_kredit', 0) or 0) # Compute laba/rugi pendapatan = {k: v for k, v in saldo_akun.items() if str(k).startswith('4')} beban = {k: v for k, v in saldo_akun.items() if str(k).startswith('5') or str(k).startswith('6')} total_pendapatan = sum((v['kredit'] - v['debit']) for v in pendapatan.values()) total_beban = sum((v['debit'] - v['kredit']) for v in beban.values()) laba_rugi = total_pendapatan - total_beban # Build adjusted balances (exclude nominal accounts) rows_to_save = [] total_aktiva = 0.0 total_pasiva_modal = 0.0 for kode, data in sorted(saldo_akun.items()): kode_str = str(kode) tipe = 'aktiva' if kode_str.startswith('1') else 'pasiva' if kode_str.startswith('2') else 'modal' if kode_str.startswith('3') else 'pendapatan' if kode_str.startswith('4') else 'beban' if kode_str.startswith('5') or kode_str.startswith('6') else 'lainnya' if tipe in ('pendapatan', 'beban'): continue if tipe == 'aktiva': saldo = data['debit'] - data['kredit'] if abs(saldo) < 0.01: continue # aktiva -> saldo_debit rows_to_save.append({'kode_akun': kode, 'nama_akun': data.get('nama',''), 'saldo_debit': round(saldo,2), 'saldo_kredit': 0.0, 'jenis_akun': tipe}) total_aktiva += saldo else: # pasiva or modal -> saldo_kredit saldo = data['kredit'] - data['debit'] if abs(saldo) < 0.01: continue rows_to_save.append({'kode_akun': kode, 'nama_akun': data.get('nama',''), 'saldo_debit': 0.0, 'saldo_kredit': round(saldo,2), 'jenis_akun': tipe}) total_pasiva_modal += saldo # Add laba_rugi to modal account if abs(laba_rugi) >= 0.01: modal_kode = KODE_AKUN.get('modal', {}).get('kode') modal_nama = KODE_AKUN.get('modal', {}).get('nama') if modal_kode: # find existing modal row found = next((r for r in rows_to_save if r['kode_akun'] == modal_kode), None) if found: if laba_rugi > 0: found['saldo_kredit'] = round(found.get('saldo_kredit',0) + laba_rugi,2) else: found['saldo_debit'] = round(found.get('saldo_debit',0) + abs(laba_rugi),2) else: if laba_rugi > 0: rows_to_save.append({'kode_akun': modal_kode, 'nama_akun': modal_nama or '', 'saldo_debit': 0.0, 'saldo_kredit': round(laba_rugi,2), 'jenis_akun': 'modal'}) else: rows_to_save.append({'kode_akun': modal_kode, 'nama_akun': modal_nama or '', 'saldo_debit': round(abs(laba_rugi),2), 'saldo_kredit': 0.0, 'jenis_akun': 'modal'}) # Prepare payloads in same format as neraca_setelah_penyesuaian payloads = [] for r in rows_to_save: payloads.append({ 'tanggal': str(tanggal) if tanggal is not None else None, 'kode_akun': str(r['kode_akun']), 'nama_akun': r.get('nama_akun',''), 'saldo_debit': float(r.get('saldo_debit',0) or 0), 'saldo_kredit': float(r.get('saldo_kredit',0) or 0), 'jenis_akun': r.get('jenis_akun',''), 'keterangan': f'Neraca setelah penutupan per {tanggal}', 'user_id': sibal_data.active_user_id }) if not payloads: return html.Div([html.Span('Tidak ada baris disimpan (saldo kosong atau error).', style={'color': COLORS['warning']})]) # Optional: delete existing rows for same tanggal + user to avoid duplicates try: if sibal_data and getattr(sibal_data, 'active_user_id', None) is not None: del_resp = supabase_client.table('neraca_setelah_penutup').delete().eq('tanggal', str(tanggal)).eq('user_id', sibal_data.active_user_id).execute() else: del_resp = None except Exception as e: # proceed but capture warning del_resp = {'error': str(e)} # Insert payloads in batch (use supabase client directly to get feedback) inserted = 0 errors = [] for p in payloads: try: resp = supabase_client.table('neraca_setelah_penutup').insert(p).execute() # supabase-python returns object with .status_code or .data if getattr(resp, 'status_code', 0) in (200, 201) or getattr(resp, 'data', None): inserted += 1 else: errors.append(str(p.get('kode_akun'))) except Exception as e: errors.append(f"{p.get('kode_akun')}:{e}") if inserted == 0: err_msg = 'Tidak ada baris disimpan (saldo kosong atau error).' if errors: err_msg += ' Error: ' + '; '.join(errors) return html.Div([html.Span(err_msg, style={'color': COLORS['warning']})]) msg = f"✅ Disimpan {inserted} baris ke tabel 'neraca_setelah_penutup'" if errors: msg += f" — gagal menyimpan kode: {', '.join(errors)}" return html.Div([html.Span(msg, style={'color': COLORS['success']} )]) @app.callback( Output('tabel-neraca-saldo-penyesuaian', 'children'), Input('btn-generate-neraca-saldo-penyesuaian', 'n_clicks'), State('tanggal-neraca-saldo-penyesuaian', 'date'), prevent_initial_call=True ) def generate_neraca_saldo_penyesuaian(n_clicks, tanggal): if not (n_clicks and n_clicks > 0): return html.P("Klik 'Generate Neraca Saldo Setelah Penyesuaian' untuk melihat neraca saldo", style={'color': '#6c757d'}) # Kumpulkan saldo dari jurnal umum + penyesuaian saldo_akun = {} 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']} 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']} saldo_akun[kode_kredit]['kredit'] += jurnal['jumlah_kredit'] # Buat baris neraca saldo: tampilkan debit atau kredit sesuai saldo rows = [] total_debit = 0 total_kredit = 0 for kode, data in sorted(saldo_akun.items()): saldo = data['debit'] - data['kredit'] if saldo > 0: debit_amt = saldo kredit_amt = 0 elif saldo < 0: debit_amt = 0 kredit_amt = abs(saldo) else: debit_amt = 0 kredit_amt = 0 if debit_amt != 0 or kredit_amt != 0: rows.append(html.Tr([html.Td(kode), html.Td(data['nama']), html.Td(format_rupiah(debit_amt) if debit_amt else ''), html.Td(format_rupiah(kredit_amt) if kredit_amt else '')])) total_debit += debit_amt total_kredit += kredit_amt header = html.Tr([html.Th('Kode'), html.Th('Nama Akun'), html.Th('Debit'), html.Th('Kredit')]) # Tambahkan baris total if rows: rows.append(html.Tr([html.Td('', 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'})])) balans = abs(total_debit - total_kredit) < 0.01 status = html.P('✅ Neraca Saldo Seimbang' if balans else '❌ Neraca Saldo Tidak Seimbang', style={'textAlign': 'center', 'fontWeight': 'bold', 'color': COLORS['success'] if balans else COLORS['error']}) return html.Div([html.H4(f'Neraca Saldo Setelah Penyesuaian per {tanggal}', style={'textAlign': 'center', 'marginBottom': '20px', 'color': COLORS['primary']}), html.Table([header] + rows, className='modern-table', style={'width': '100%'}), status]) return html.P('Tidak ada saldo akun untuk ditampilkan', style={'color': '#6c757d'}) @app.callback( Output('save-neraca-saldo-penyesuaian-status', 'children'), Input('btn-save-neraca-saldo-penyesuaian', 'n_clicks'), State('tanggal-neraca-saldo-penyesuaian', 'date'), prevent_initial_call=True ) def save_neraca_saldo_penyesuaian(n_clicks, tanggal): if not (n_clicks and n_clicks > 0): return '' # Recompute saldo per akun (gabungkan jurnal umum + penyesuaian) saldo_akun = {} semua_jurnal = sibal_data.jurnal_umum + sibal_data.jurnal_penyesuaian for jurnal in semua_jurnal: kode_debit = jurnal.get('kode_akun_debit') kode_kredit = jurnal.get('kode_akun_kredit') if kode_debit: if kode_debit not in saldo_akun: saldo_akun[kode_debit] = {'kode': kode_debit, 'nama': jurnal.get('akun_debit'), 'debit': 0.0, 'kredit': 0.0} saldo_akun[kode_debit]['debit'] += float(jurnal.get('jumlah_debit', 0) or 0) if kode_kredit: if kode_kredit not in saldo_akun: saldo_akun[kode_kredit] = {'kode': kode_kredit, 'nama': jurnal.get('akun_kredit'), 'debit': 0.0, 'kredit': 0.0} saldo_akun[kode_kredit]['kredit'] += float(jurnal.get('jumlah_kredit', 0) or 0) # Prepare and insert rows into Supabase table 'neraca_setelah_penyesuaian' inserted = 0 errors = [] for kode, data in sorted(saldo_akun.items()): debit_amt = data['debit'] - 0 kredit_amt = data['kredit'] - 0 # skip zero balances if abs(debit_amt) < 0.01 and abs(kredit_amt) < 0.01: continue # Compute final balance: if debit > credit => saldo_debit = debit-credit; else saldo_kredit = credit-debit if debit_amt >= kredit_amt: saldo_debit = round(debit_amt - kredit_amt, 2) saldo_kredit = 0.0 else: saldo_debit = 0.0 saldo_kredit = round(kredit_amt - debit_amt, 2) # Determine jenis akun from kode (best-effort) kode_str = str(kode) jenis = 'aktiva' if kode_str.startswith('1') else 'pasiva' if kode_str.startswith('2') else 'modal' if kode_str.startswith('3') else 'pendapatan' if kode_str.startswith('4') else 'beban' if kode_str.startswith('5') or kode_str.startswith('6') else 'lainnya' payload = { 'tanggal': tanggal, 'kode_akun': kode, 'nama_akun': data.get('nama') or '', 'saldo_debit': float(saldo_debit), 'saldo_kredit': float(saldo_kredit), 'jenis_akun': jenis, 'keterangan': f'Neraca saldo setelah penyesuaian per {tanggal}', 'user_id': sibal_data.active_user_id } saved = sibal_data._insert_table_data('neraca_setelah_penyesuaian', payload) if saved: inserted += 1 else: errors.append(str(kode)) if inserted == 0: return html.Div([html.Span('Tidak ada baris disimpan (saldo kosong atau error).', style={'color': COLORS['warning']})]) msg = f"✅ Disimpan {inserted} baris ke tabel 'neraca_setelah_penyesuaian'" if errors: msg += f" — gagal menyimpan kode: {', '.join(errors)}" return html.Div([html.Span(msg, style={'color': COLORS['success']} )]) 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")