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