Init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,583 @@
|
||||
from flask import Flask, abort, redirect, render_template, request, url_for
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
app = Flask(__name__)
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
SETTINGS_DB = BASE_DIR / 'dashboard_settings.db'
|
||||
DISPLAY_DATE_FORMAT = '%d-%m-%Y'
|
||||
|
||||
def load_env_file(env_path):
|
||||
if not env_path.exists():
|
||||
return
|
||||
|
||||
with env_path.open() as env_file:
|
||||
for line in env_file:
|
||||
stripped_line = line.strip()
|
||||
if not stripped_line or stripped_line.startswith('#') or '=' not in stripped_line:
|
||||
continue
|
||||
|
||||
key, value = stripped_line.split('=', 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
def get_configured_db_path(env_name, default_file_name):
|
||||
db_path = Path(os.environ.get(env_name, default_file_name)).expanduser()
|
||||
if not db_path.is_absolute():
|
||||
db_path = BASE_DIR / db_path
|
||||
|
||||
return db_path
|
||||
|
||||
load_env_file(BASE_DIR / '.env')
|
||||
|
||||
KARUNG_TUANG_DB = get_configured_db_path('KARUNG_TUANG_DB', 'karung_tuang.db')
|
||||
KARUNG_MASUK_DB = get_configured_db_path('KARUNG_MASUK_DB', 'karung_masuk.db')
|
||||
|
||||
if KARUNG_TUANG_DB.resolve() == KARUNG_MASUK_DB.resolve():
|
||||
raise RuntimeError('KARUNG_TUANG_DB and KARUNG_MASUK_DB must point to different database files.')
|
||||
|
||||
DB_CONFIGS = {
|
||||
'tuang': {
|
||||
'path': KARUNG_TUANG_DB,
|
||||
'table': 'counter_data',
|
||||
},
|
||||
'masuk': {
|
||||
'path': KARUNG_MASUK_DB,
|
||||
'table': 'karung_counts',
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_DASHBOARD_SETTINGS = {
|
||||
'id': '',
|
||||
'cycle_name': 'Siklus Aktif',
|
||||
'cycle_start': '',
|
||||
'cycle_end': '',
|
||||
'saldo_awal': 0,
|
||||
}
|
||||
|
||||
def get_db_data(db_path, table_name):
|
||||
"""Fetch all data from database"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT id, camera_name, date, counter_value FROM {table_name} ORDER BY date DESC")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append({
|
||||
'id': row[0],
|
||||
'camera_name': row[1],
|
||||
'date': row[2],
|
||||
'counter_value': row[3]
|
||||
})
|
||||
return data
|
||||
|
||||
def load_dashboard_settings():
|
||||
ensure_settings_db()
|
||||
|
||||
with sqlite3.connect(SETTINGS_DB) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, cycle_name, cycle_start, cycle_end, saldo_awal
|
||||
FROM cycle_settings
|
||||
ORDER BY is_active DESC, id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return DEFAULT_DASHBOARD_SETTINGS.copy()
|
||||
|
||||
return {
|
||||
'id': row[0],
|
||||
'cycle_name': row[1],
|
||||
'cycle_start': row[2],
|
||||
'cycle_end': row[3],
|
||||
'saldo_awal': row[4],
|
||||
}
|
||||
|
||||
def save_dashboard_settings(settings):
|
||||
ensure_settings_db()
|
||||
|
||||
with sqlite3.connect(SETTINGS_DB) as conn:
|
||||
conn.execute('UPDATE cycle_settings SET is_active = 0')
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO cycle_settings (
|
||||
cycle_name,
|
||||
cycle_start,
|
||||
cycle_end,
|
||||
saldo_awal,
|
||||
is_active,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 1, ?)
|
||||
""",
|
||||
(
|
||||
settings['cycle_name'],
|
||||
settings['cycle_start'],
|
||||
settings['cycle_end'],
|
||||
settings['saldo_awal'],
|
||||
datetime.now().isoformat(timespec='seconds')
|
||||
)
|
||||
)
|
||||
|
||||
def get_cycle_options():
|
||||
ensure_settings_db()
|
||||
|
||||
with sqlite3.connect(SETTINGS_DB) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT latest_cycle.id,
|
||||
latest_cycle.cycle_name,
|
||||
latest_cycle.cycle_start,
|
||||
latest_cycle.cycle_end,
|
||||
latest_cycle.saldo_awal
|
||||
FROM cycle_settings AS latest_cycle
|
||||
INNER JOIN (
|
||||
SELECT cycle_name, MAX(id) AS latest_id
|
||||
FROM cycle_settings
|
||||
GROUP BY cycle_name
|
||||
) AS grouped_cycle
|
||||
ON latest_cycle.id = grouped_cycle.latest_id
|
||||
ORDER BY latest_cycle.cycle_name COLLATE NOCASE
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': row[0],
|
||||
'cycle_name': row[1],
|
||||
'cycle_start': row[2],
|
||||
'cycle_end': row[3],
|
||||
'saldo_awal': row[4],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def select_cycle_by_name(cycle_name):
|
||||
ensure_settings_db()
|
||||
|
||||
with sqlite3.connect(SETTINGS_DB) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM cycle_settings
|
||||
WHERE cycle_name = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(cycle_name,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
abort(404, description='Cycle name was not found.')
|
||||
|
||||
conn.execute('UPDATE cycle_settings SET is_active = 0')
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE cycle_settings
|
||||
SET is_active = 1,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(datetime.now().isoformat(timespec='seconds'), row[0])
|
||||
)
|
||||
|
||||
def ensure_settings_db():
|
||||
with sqlite3.connect(SETTINGS_DB) as conn:
|
||||
existing_schema = conn.execute(
|
||||
"""
|
||||
SELECT sql
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name = 'cycle_settings'
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
if existing_schema and 'CHECK (id = 1)' in existing_schema[0]:
|
||||
conn.execute('ALTER TABLE cycle_settings RENAME TO cycle_settings_old')
|
||||
create_cycle_settings_table(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO cycle_settings (
|
||||
cycle_name,
|
||||
cycle_start,
|
||||
cycle_end,
|
||||
saldo_awal,
|
||||
is_active,
|
||||
updated_at
|
||||
)
|
||||
SELECT cycle_name,
|
||||
cycle_start,
|
||||
cycle_end,
|
||||
saldo_awal,
|
||||
0,
|
||||
updated_at
|
||||
FROM cycle_settings_old
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
conn.execute('DROP TABLE cycle_settings_old')
|
||||
else:
|
||||
create_cycle_settings_table(conn)
|
||||
ensure_settings_active_column(conn)
|
||||
|
||||
settings_count = conn.execute('SELECT COUNT(*) FROM cycle_settings').fetchone()[0]
|
||||
if settings_count:
|
||||
ensure_active_cycle(conn)
|
||||
return
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO cycle_settings (
|
||||
cycle_name,
|
||||
cycle_start,
|
||||
cycle_end,
|
||||
saldo_awal,
|
||||
is_active,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 1, ?)
|
||||
""",
|
||||
(
|
||||
DEFAULT_DASHBOARD_SETTINGS['cycle_name'],
|
||||
DEFAULT_DASHBOARD_SETTINGS['cycle_start'],
|
||||
DEFAULT_DASHBOARD_SETTINGS['cycle_end'],
|
||||
DEFAULT_DASHBOARD_SETTINGS['saldo_awal'],
|
||||
datetime.now().isoformat(timespec='seconds')
|
||||
)
|
||||
)
|
||||
|
||||
def create_cycle_settings_table(conn):
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cycle_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cycle_name TEXT NOT NULL,
|
||||
cycle_start TEXT NOT NULL DEFAULT '',
|
||||
cycle_end TEXT NOT NULL DEFAULT '',
|
||||
saldo_awal INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def ensure_settings_active_column(conn):
|
||||
columns = {
|
||||
row[1]
|
||||
for row in conn.execute('PRAGMA table_info(cycle_settings)').fetchall()
|
||||
}
|
||||
if 'is_active' not in columns:
|
||||
conn.execute(
|
||||
"""
|
||||
ALTER TABLE cycle_settings
|
||||
ADD COLUMN is_active INTEGER NOT NULL DEFAULT 0
|
||||
"""
|
||||
)
|
||||
|
||||
def ensure_active_cycle(conn):
|
||||
active_count = conn.execute(
|
||||
'SELECT COUNT(*) FROM cycle_settings WHERE is_active = 1'
|
||||
).fetchone()[0]
|
||||
if active_count:
|
||||
return
|
||||
|
||||
latest_id = conn.execute(
|
||||
'SELECT id FROM cycle_settings ORDER BY id DESC LIMIT 1'
|
||||
).fetchone()[0]
|
||||
conn.execute('UPDATE cycle_settings SET is_active = 1 WHERE id = ?', (latest_id,))
|
||||
|
||||
def parse_cycle_date(date_text, field_name):
|
||||
date_text = date_text.strip()
|
||||
if not date_text:
|
||||
return None
|
||||
|
||||
for date_format in (DISPLAY_DATE_FORMAT, '%Y-%m-%d'):
|
||||
try:
|
||||
return datetime.strptime(date_text, date_format).date()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
abort(400, description=f'{field_name} must use DD-MM-YYYY or YYYY-MM-DD format.')
|
||||
|
||||
def parse_data_date(date_text):
|
||||
date_text = str(date_text).strip()
|
||||
if not date_text:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(date_text.replace('Z', '+00:00')).date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for date_format in (DISPLAY_DATE_FORMAT, '%d/%m/%Y'):
|
||||
try:
|
||||
return datetime.strptime(date_text, date_format).date()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def format_history_date(date_text):
|
||||
parsed_date = parse_data_date(date_text)
|
||||
if not parsed_date:
|
||||
return date_text
|
||||
|
||||
return parsed_date.strftime('%Y-%m-%d')
|
||||
|
||||
def format_date_picker_value(date_text):
|
||||
parsed_date = parse_cycle_date(date_text, 'Cycle date')
|
||||
if not parsed_date:
|
||||
return ''
|
||||
|
||||
return parsed_date.strftime('%Y-%m-%d')
|
||||
|
||||
def filter_data_by_cycle(data, cycle_start, cycle_end):
|
||||
if not cycle_start or not cycle_end:
|
||||
return []
|
||||
|
||||
filtered_data = []
|
||||
for item in data:
|
||||
item_date = parse_data_date(item['date'])
|
||||
if not item_date:
|
||||
continue
|
||||
if item_date < cycle_start:
|
||||
continue
|
||||
if item_date > cycle_end:
|
||||
continue
|
||||
|
||||
filtered_data.append(item)
|
||||
|
||||
return filtered_data
|
||||
|
||||
def is_masuk_out_item(item):
|
||||
return str(item['camera_name']).lower().endswith('_out')
|
||||
|
||||
def get_settings_from_form():
|
||||
cycle_name = request.form.get('cycle_name', '').strip()
|
||||
cycle_start_raw = request.form.get('cycle_start', '').strip()
|
||||
cycle_end_raw = request.form.get('cycle_end', '').strip()
|
||||
saldo_awal_raw = request.form.get('saldo_awal', '0').strip()
|
||||
|
||||
if not cycle_name:
|
||||
abort(400, description='Cycle name is required.')
|
||||
if not cycle_start_raw or not cycle_end_raw:
|
||||
abort(400, description='Cycle start and cycle end are required to filter historical data.')
|
||||
|
||||
cycle_start = parse_cycle_date(cycle_start_raw, 'Cycle start')
|
||||
cycle_end = parse_cycle_date(cycle_end_raw, 'Cycle end')
|
||||
|
||||
if cycle_start and cycle_end and cycle_start > cycle_end:
|
||||
abort(400, description='Cycle start must be before or equal to cycle end.')
|
||||
|
||||
try:
|
||||
saldo_awal = int(saldo_awal_raw or 0)
|
||||
except ValueError:
|
||||
abort(400, description='Saldo awal must be a whole number.')
|
||||
|
||||
if saldo_awal < 0:
|
||||
abort(400, description='Saldo awal cannot be negative.')
|
||||
|
||||
return {
|
||||
'cycle_name': cycle_name,
|
||||
'cycle_start': cycle_start.strftime(DISPLAY_DATE_FORMAT) if cycle_start else '',
|
||||
'cycle_end': cycle_end.strftime(DISPLAY_DATE_FORMAT) if cycle_end else '',
|
||||
'saldo_awal': saldo_awal,
|
||||
}
|
||||
|
||||
def ensure_db_writable(db_path):
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
abort(404, description=f'Database file not found: {db_path}')
|
||||
if not os.access(db_path, os.W_OK):
|
||||
abort(403, description=f'Cannot save jumlah karung because database is not writable: {db_path}')
|
||||
if not os.access(db_path.parent, os.W_OK):
|
||||
abort(403, description=f'Cannot save jumlah karung because database directory is not writable: {db_path.parent}')
|
||||
|
||||
def update_db_data(db_path, table_name, row_id, counter_value):
|
||||
"""Update one database row by primary key."""
|
||||
ensure_db_writable(db_path)
|
||||
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET counter_value = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(counter_value, row_id)
|
||||
)
|
||||
updated_count = cursor.rowcount
|
||||
except sqlite3.OperationalError as error:
|
||||
abort(403, description=f'Cannot save jumlah karung to database {db_path}: {error}')
|
||||
|
||||
if updated_count == 0:
|
||||
abort(404, description='No database row was found for the requested edit.')
|
||||
|
||||
def update_db_rows(db_path, table_name, row_values):
|
||||
ensure_db_writable(db_path)
|
||||
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
for row_id, counter_value in row_values.items():
|
||||
cursor.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET counter_value = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(counter_value, row_id)
|
||||
)
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
abort(404, description='No database row was found for one of the requested edits.')
|
||||
except sqlite3.OperationalError as error:
|
||||
abort(403, description=f'Cannot save jumlah karung to database {db_path}: {error}')
|
||||
|
||||
def get_counter_value_from_form(counter_value_raw):
|
||||
if not counter_value_raw:
|
||||
abort(400, description='Jumlah karung is required to edit database data.')
|
||||
|
||||
try:
|
||||
counter_value = int(counter_value_raw)
|
||||
except ValueError:
|
||||
abort(400, description='Jumlah karung must be a whole number.')
|
||||
|
||||
if counter_value < 0:
|
||||
abort(400, description='Jumlah karung cannot be negative.')
|
||||
|
||||
return counter_value
|
||||
|
||||
def get_bulk_counter_values_from_form():
|
||||
row_values = {}
|
||||
|
||||
for field_name, field_value in request.form.items():
|
||||
if not field_name.startswith('counter_value_'):
|
||||
continue
|
||||
|
||||
row_id_raw = field_name.removeprefix('counter_value_')
|
||||
try:
|
||||
row_id = int(row_id_raw)
|
||||
except ValueError:
|
||||
abort(400, description='Invalid database row id in edit form.')
|
||||
|
||||
row_values[row_id] = get_counter_value_from_form(field_value.strip())
|
||||
|
||||
if not row_values:
|
||||
abort(400, description='No jumlah karung values were submitted.')
|
||||
|
||||
return row_values
|
||||
|
||||
def get_json_data(json_path):
|
||||
"""Fetch current data from JSON file"""
|
||||
try:
|
||||
with open(json_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
@app.route('/database/<db_name>/<int:row_id>/edit', methods=['POST'])
|
||||
def edit_db_row(db_name, row_id):
|
||||
db_config = DB_CONFIGS.get(db_name)
|
||||
if not db_config:
|
||||
abort(404, description='Unknown database. Use tuang or masuk.')
|
||||
|
||||
counter_value = get_counter_value_from_form(request.form.get('counter_value', '').strip())
|
||||
|
||||
update_db_data(
|
||||
db_config['path'],
|
||||
db_config['table'],
|
||||
row_id,
|
||||
counter_value
|
||||
)
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.route('/database/<db_name>/edit', methods=['POST'])
|
||||
def edit_db_rows(db_name):
|
||||
db_config = DB_CONFIGS.get(db_name)
|
||||
if not db_config:
|
||||
abort(404, description='Unknown database. Use tuang or masuk.')
|
||||
|
||||
update_db_rows(
|
||||
db_config['path'],
|
||||
db_config['table'],
|
||||
get_bulk_counter_values_from_form()
|
||||
)
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.route('/settings', methods=['POST'])
|
||||
def update_dashboard_settings():
|
||||
settings = get_settings_from_form()
|
||||
save_dashboard_settings(settings)
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.route('/settings/select', methods=['POST'])
|
||||
def select_dashboard_cycle():
|
||||
cycle_name = request.form.get('cycle_name', '').strip()
|
||||
if not cycle_name:
|
||||
abort(400, description='Cycle name is required to select a cycle.')
|
||||
|
||||
select_cycle_by_name(cycle_name)
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
dashboard_settings = load_dashboard_settings()
|
||||
cycle_options = get_cycle_options()
|
||||
cycle_start = parse_cycle_date(dashboard_settings['cycle_start'], 'Cycle start')
|
||||
cycle_end = parse_cycle_date(dashboard_settings['cycle_end'], 'Cycle end')
|
||||
cycle_start_picker_value = format_date_picker_value(dashboard_settings['cycle_start'])
|
||||
cycle_end_picker_value = format_date_picker_value(dashboard_settings['cycle_end'])
|
||||
|
||||
# Get historical data from databases
|
||||
tuang_db_data = get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table'])
|
||||
masuk_db_data = get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table'])
|
||||
tuang_db_data = filter_data_by_cycle(tuang_db_data, cycle_start, cycle_end)
|
||||
masuk_db_data = filter_data_by_cycle(masuk_db_data, cycle_start, cycle_end)
|
||||
for item in tuang_db_data:
|
||||
item['display_date'] = format_history_date(item['date'])
|
||||
|
||||
# Get current date data from JSON files
|
||||
tuang_json_data = get_json_data('/etc/frigate-counter/karung-tuang-2/karung_tuang.json')
|
||||
masuk_json_data = get_json_data('/etc/frigate-counter/karung-masuk/karung_masuk.json')
|
||||
|
||||
# Calculate totals from JSON (current date)
|
||||
tuang_json_total = sum(item.get('karung', 0) for item in tuang_json_data.values())
|
||||
masuk_json_total = sum(item.get('karung', 0) for item in masuk_json_data.values())
|
||||
|
||||
# Calculate totals from database (historical)
|
||||
tuang_db_total = sum(item['counter_value'] for item in tuang_db_data)
|
||||
masuk_db_total = sum(
|
||||
item['counter_value'] if not is_masuk_out_item(item) else -item['counter_value']
|
||||
for item in masuk_db_data
|
||||
)
|
||||
saldo_akhir = dashboard_settings['saldo_awal'] + masuk_db_total - tuang_db_total
|
||||
|
||||
current_date = datetime.now().strftime(DISPLAY_DATE_FORMAT)
|
||||
|
||||
return render_template('index.html',
|
||||
dashboard_settings=dashboard_settings,
|
||||
cycle_options=cycle_options,
|
||||
cycle_start_picker_value=cycle_start_picker_value,
|
||||
cycle_end_picker_value=cycle_end_picker_value,
|
||||
tuang_db_data=tuang_db_data,
|
||||
masuk_db_data=masuk_db_data,
|
||||
tuang_json_data=tuang_json_data,
|
||||
masuk_json_data=masuk_json_data,
|
||||
tuang_json_total=tuang_json_total,
|
||||
masuk_json_total=masuk_json_total,
|
||||
tuang_db_total=tuang_db_total,
|
||||
masuk_db_total=masuk_db_total,
|
||||
saldo_akhir=saldo_akhir,
|
||||
current_date=current_date)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=8080)
|
||||
@@ -0,0 +1 @@
|
||||
COUNTER_UUID=9f556d2a-af5c-41b0-bcd0-6cea0dfa0fed
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"kandang_1_karung_masuk": {
|
||||
"karung": 0
|
||||
},
|
||||
"kandang_1_karung_masuk_out": {
|
||||
"karung": 0
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"kandang_atas_feeder_kiri": {
|
||||
"karung": 0
|
||||
},
|
||||
"kandang_bawah_feeder_kanan": {
|
||||
"karung": 0
|
||||
}
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sync counter data from SQLite databases to API.
|
||||
|
||||
karung_tuang:
|
||||
- 17:00-23:59: Syncs CURRENT date
|
||||
- 00:00-16:59: Syncs PREVIOUS date (only if not already synced)
|
||||
|
||||
karung_masuk: Syncs PREVIOUS date from 00:00 onwards, only if data differs
|
||||
(skips if already successfully synced for that date).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def get_db_path(db_name):
|
||||
"""Get database path in same directory as script."""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(script_dir, db_name)
|
||||
|
||||
|
||||
def get_previous_date():
|
||||
"""Get yesterday's date in YYYY-MM-DD format."""
|
||||
return (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def get_sync_state_file():
|
||||
"""Get path to sync state file."""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(script_dir, ".sync_state.json")
|
||||
|
||||
|
||||
def load_sync_state():
|
||||
"""Load sync state from file."""
|
||||
state_file = get_sync_state_file()
|
||||
if os.path.exists(state_file):
|
||||
try:
|
||||
with open(state_file, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_sync_state(state):
|
||||
"""Save sync state to file."""
|
||||
state_file = get_sync_state_file()
|
||||
try:
|
||||
with open(state_file, "w") as f:
|
||||
json.dump(state, f)
|
||||
except IOError as e:
|
||||
print(f"Warning: Could not save sync state: {e}")
|
||||
|
||||
|
||||
def is_already_synced(state, db_name, date):
|
||||
"""Check if database was already synced for given date."""
|
||||
return state.get(db_name, {}).get("last_synced_date") == date
|
||||
|
||||
|
||||
def mark_synced(state, db_name, date):
|
||||
"""Mark database as synced for given date."""
|
||||
if db_name not in state:
|
||||
state[db_name] = {}
|
||||
state[db_name]["last_synced_date"] = date
|
||||
|
||||
|
||||
def get_current_date():
|
||||
"""Get today's date in YYYY-MM-DD format."""
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def get_local_data(db_path, table_name, date):
|
||||
"""Get counter data from SQLite database for a specific date."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
if table_name == "karung_counts":
|
||||
cursor.execute(
|
||||
"SELECT camera_name, date, counter_value FROM karung_counts WHERE date = ?",
|
||||
(date,),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"SELECT camera_name, date(date) as d, counter_value FROM counter_data WHERE date(date) = ?",
|
||||
(date,),
|
||||
)
|
||||
|
||||
results = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
data = {}
|
||||
for camera_name, date_val, counter_value in results:
|
||||
key = f"{camera_name}_{date_val}"
|
||||
data[key] = {
|
||||
"camera_name": camera_name,
|
||||
"date": date_val,
|
||||
"value": counter_value,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
def fetch_api_data(uuid):
|
||||
"""Fetch counter data from API."""
|
||||
url = f"https://dashboard.cpsp.id/api/cpsp/counter/{uuid}/"
|
||||
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
req.add_header("Accept", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
if result.get("success") and "data" in result:
|
||||
return result["data"].get("camera_counter", [])
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"HTTP Error {e.code}: {e.reason}")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"URL Error: {e.reason}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"JSON Decode Error: {e}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def update_api(uuid, camera_name, date, value):
|
||||
"""Update counter data via API (dummy endpoint)."""
|
||||
# Dummy endpoint - replace with actual endpoint when available
|
||||
url = f"https://dashboard.cpsp.id/api/cpsp/counter/{uuid}/update/"
|
||||
|
||||
payload = {"camera_name": camera_name, "date": date, "value": value}
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Accept", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
print(f"Updated {camera_name} for {date}: {value}")
|
||||
return result
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"HTTP Error {e.code} updating {camera_name}: {e.reason}")
|
||||
# For dummy endpoint, just log the intended update
|
||||
print(f"[DUMMY] Would update {camera_name} for {date} with value {value}")
|
||||
return None
|
||||
except urllib.error.URLError as e:
|
||||
print(f"URL Error: {e.reason}")
|
||||
print(f"[DUMMY] Would update {camera_name} for {date} with value {value}")
|
||||
return None
|
||||
|
||||
|
||||
def sync_database(db_config, date, uuid, state=None, skip_if_synced=False):
|
||||
"""Sync a single database with API.
|
||||
|
||||
Args:
|
||||
db_config: Database configuration dict
|
||||
date: Date string to sync
|
||||
uuid: API UUID
|
||||
state: Optional sync state dict
|
||||
skip_if_synced: If True, skip sync if already marked as synced for this date
|
||||
"""
|
||||
db_name = db_config.get("name", "unknown")
|
||||
|
||||
# Check if already synced (for karung_masuk to avoid redundant syncs)
|
||||
if skip_if_synced and state and is_already_synced(state, db_name, date):
|
||||
print(f"Skipping {db_name} - already synced for {date}")
|
||||
return True
|
||||
|
||||
db_path = get_db_path(db_config["db_file"])
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database not found: {db_path}")
|
||||
return False
|
||||
|
||||
# Get local data
|
||||
local_data = get_local_data(db_path, db_config["table"], date)
|
||||
|
||||
if not local_data:
|
||||
print(f"No local data found for {date} in {db_config['db_file']}")
|
||||
return False
|
||||
|
||||
# Get API data
|
||||
api_data = fetch_api_data(uuid)
|
||||
|
||||
# Build API data map
|
||||
api_map = {}
|
||||
for item in api_data:
|
||||
key = f"{item['camera_name']}_{item['date']}"
|
||||
api_map[key] = item["value"]
|
||||
|
||||
has_mismatch = False
|
||||
|
||||
# Compare and sync
|
||||
for key, local_record in local_data.items():
|
||||
camera_name = local_record["camera_name"]
|
||||
local_value = local_record["value"]
|
||||
|
||||
if key not in api_map:
|
||||
print(f"New record: {camera_name} for {date} = {local_value}")
|
||||
update_api(uuid, camera_name, date, local_value)
|
||||
has_mismatch = True
|
||||
elif api_map[key] != local_value:
|
||||
print(
|
||||
f"Mismatch for {camera_name} on {date}: API={api_map[key]}, Local={local_value}"
|
||||
)
|
||||
update_api(uuid, camera_name, date, local_value)
|
||||
has_mismatch = True
|
||||
else:
|
||||
print(f"Synced: {camera_name} for {date} = {local_value}")
|
||||
|
||||
# Mark as synced if no mismatches found (all data matches)
|
||||
if not has_mismatch and state:
|
||||
mark_synced(state, db_name, date)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main sync function.
|
||||
|
||||
karung_tuang:
|
||||
- 17:00-23:59: Sync CURRENT date
|
||||
- 00:00-16:59: Sync PREVIOUS date (only if not already synced)
|
||||
|
||||
karung_masuk: Sync PREVIOUS date from 00:00 onwards, only if data differs.
|
||||
"""
|
||||
uuid = os.environ.get("COUNTER_UUID")
|
||||
if not uuid:
|
||||
print("Error: COUNTER_UUID environment variable not set")
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
hour = now.hour
|
||||
|
||||
# Load sync state to track which dates have been synced
|
||||
sync_state = load_sync_state()
|
||||
|
||||
# Database configurations
|
||||
db_configs = {
|
||||
"karung_tuang": {
|
||||
"name": "karung_tuang",
|
||||
"db_file": "karung_tuang.db",
|
||||
"table": "counter_data",
|
||||
# 17:00-23:59: sync current date
|
||||
# 00:00-16:59: sync previous date (if not already synced)
|
||||
"evening_start": 17,
|
||||
"use_current_date_evening": True,
|
||||
"use_current_date_morning": False,
|
||||
"skip_if_synced_morning": True,
|
||||
},
|
||||
"karung_masuk": {
|
||||
"name": "karung_masuk",
|
||||
"db_file": "karung_masuk.db",
|
||||
"table": "karung_counts",
|
||||
"start_hour": 0, # Sync starts at 00:00
|
||||
"use_current_date": False, # Sync previous date
|
||||
"skip_if_synced": True, # Skip if already synced for this date
|
||||
},
|
||||
}
|
||||
|
||||
# Get dates
|
||||
prev_date = get_previous_date()
|
||||
curr_date = get_current_date()
|
||||
|
||||
print(f"Current hour: {hour}:00")
|
||||
print(f"Previous date: {prev_date}")
|
||||
print(f"Current date: {curr_date}")
|
||||
|
||||
# Sync databases based on current hour
|
||||
for name, config in db_configs.items():
|
||||
# Handle karung_tuang with morning/evening windows
|
||||
if "evening_start" in config:
|
||||
evening_start = config["evening_start"]
|
||||
|
||||
if hour >= evening_start:
|
||||
# Evening window (17:00-23:59): sync current date
|
||||
sync_date = curr_date
|
||||
skip_if_synced = False
|
||||
print(f"\nSyncing {name} (evening window {evening_start:02d}:00-23:59) for date {sync_date}...")
|
||||
else:
|
||||
# Morning window (00:00-16:59): sync previous date, skip if already synced
|
||||
sync_date = prev_date
|
||||
skip_if_synced = config.get("skip_if_synced_morning", True)
|
||||
print(f"\nSyncing {name} (morning window 00:00-{evening_start:02d}:00) for date {sync_date}...")
|
||||
|
||||
sync_database(
|
||||
config,
|
||||
sync_date,
|
||||
uuid,
|
||||
state=sync_state,
|
||||
skip_if_synced=skip_if_synced,
|
||||
)
|
||||
else:
|
||||
# Standard behavior for other databases (karung_masuk)
|
||||
start_hour = config["start_hour"]
|
||||
end_hour = config.get("end_hour", 24)
|
||||
|
||||
if hour >= start_hour and hour < end_hour:
|
||||
sync_date = curr_date if config.get("use_current_date", False) else prev_date
|
||||
|
||||
print(f"\nSyncing {name} for date {sync_date}...")
|
||||
sync_database(
|
||||
config,
|
||||
sync_date,
|
||||
uuid,
|
||||
state=sync_state,
|
||||
skip_if_synced=config.get("skip_if_synced", False),
|
||||
)
|
||||
else:
|
||||
print(f"\nSkipping {name} - starts at {start_hour:02d}:00")
|
||||
|
||||
# Save sync state
|
||||
save_sync_state(sync_state)
|
||||
|
||||
print("\nSync complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,578 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Karung Counter Dashboard</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.selected-cycle {
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.selected-cycle h2 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.selected-cycle-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.selected-cycle-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.selected-cycle-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
word-break: break-word;
|
||||
}
|
||||
.selected-cycle-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.selected-cycle-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.selected-cycle-button {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.selected-cycle-button:hover {
|
||||
background-color: #219653;
|
||||
}
|
||||
.section {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.section h2 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 3px solid #3498db;
|
||||
}
|
||||
.section h2.tuang {
|
||||
border-bottom-color: #e74c3c;
|
||||
}
|
||||
.section h2.masuk {
|
||||
border-bottom-color: #27ae60;
|
||||
}
|
||||
.historical-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.historical-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.historical-summary h2 {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
.historical-toggle-label {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.historical-toggle-label::after {
|
||||
content: "Expand";
|
||||
}
|
||||
.historical-section[open] .historical-toggle-label::after {
|
||||
content: "Collapse";
|
||||
}
|
||||
.historical-content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.settings-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 15px;
|
||||
align-items: end;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.form-group label {
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
}
|
||||
.settings-input {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.settings-button {
|
||||
background-color: #2c3e50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 11px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.settings-button:hover {
|
||||
background-color: #1f2d3a;
|
||||
}
|
||||
.settings-hint {
|
||||
color: #7f8c8d;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.card.tuang {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
.card.masuk {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
.card.balance {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.card .value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.card .text-value {
|
||||
font-size: 24px;
|
||||
word-break: break-word;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
th {
|
||||
background-color: #34495e;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.badge-tuang {
|
||||
background-color: #ffeaa7;
|
||||
color: #d63031;
|
||||
}
|
||||
.badge-masuk {
|
||||
background-color: #a8e6cf;
|
||||
color: #00b894;
|
||||
}
|
||||
.json-section {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #3498db;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.json-section h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.camera-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
.camera-item {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e0e0e0;
|
||||
}
|
||||
.camera-item .camera-name {
|
||||
font-size: 12px;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.camera-item .karung-count {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.no-data {
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
padding: 30px;
|
||||
font-style: italic;
|
||||
}
|
||||
.filter-note {
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.date-badge {
|
||||
display: inline-block;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.edit-input {
|
||||
width: 100%;
|
||||
min-width: 120px;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.edit-input:disabled {
|
||||
background-color: #f3f4f6;
|
||||
color: #555;
|
||||
}
|
||||
.edit-input.count {
|
||||
min-width: 80px;
|
||||
}
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
.edit-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.edit-button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
.save-button {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
.save-button:hover {
|
||||
background-color: #219653;
|
||||
}
|
||||
.save-button:disabled {
|
||||
background-color: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.save-button:disabled:hover {
|
||||
background-color: #bdc3c7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📊 Karung Counter Dashboard</h1>
|
||||
|
||||
<div class="selected-cycle">
|
||||
<h2>Selected Cycle</h2>
|
||||
<div class="selected-cycle-details">
|
||||
<div>
|
||||
<span class="selected-cycle-label">Cycle Name</span>
|
||||
<form class="selected-cycle-form" action="{{ url_for('select_dashboard_cycle') }}" method="post">
|
||||
<select class="selected-cycle-select" name="cycle_name" required>
|
||||
{% for cycle in cycle_options %}
|
||||
<option value="{{ cycle.cycle_name }}" {% if cycle.cycle_name == dashboard_settings.cycle_name %}selected{% endif %}>{{ cycle.cycle_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="selected-cycle-button" type="submit">Pilih</button>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<span class="selected-cycle-label">Range</span>
|
||||
<span class="selected-cycle-value">{{ dashboard_settings.cycle_start or 'awal data' }} - {{ dashboard_settings.cycle_end or 'akhir data' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="selected-cycle-label">Saldo Awal</span>
|
||||
<span class="selected-cycle-value">{{ dashboard_settings.saldo_awal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="cards">
|
||||
<div class="card balance">
|
||||
<h3>Cycle Name</h3>
|
||||
<div class="value text-value">{{ dashboard_settings.cycle_name }}</div>
|
||||
</div>
|
||||
<div class="card tuang">
|
||||
<h3>Karung Tuang Hari Ini (JSON)</h3>
|
||||
<div class="value">{{ tuang_json_total }}</div>
|
||||
</div>
|
||||
<div class="card masuk">
|
||||
<h3>Karung Masuk Hari Ini (JSON)</h3>
|
||||
<div class="value">{{ masuk_json_total }}</div>
|
||||
</div>
|
||||
<div class="card tuang">
|
||||
<h3>Total Karung Tuang (Siklus)</h3>
|
||||
<div class="value">{{ tuang_db_total }}</div>
|
||||
</div>
|
||||
<div class="card masuk">
|
||||
<h3>Total Karung Masuk (Siklus)</h3>
|
||||
<div class="value">{{ masuk_db_total }}</div>
|
||||
</div>
|
||||
<div class="card balance">
|
||||
<h3>Saldo Awal</h3>
|
||||
<div class="value">{{ dashboard_settings.saldo_awal }}</div>
|
||||
</div>
|
||||
<div class="card balance">
|
||||
<h3>Saldo Akhir</h3>
|
||||
<div class="value">{{ saldo_akhir }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Date Data from JSON -->
|
||||
<div class="section">
|
||||
<h2>📅 Data Hari Ini ({{ current_date }}) - Dari JSON</h2>
|
||||
|
||||
<h3>Karung Tuang</h3>
|
||||
{% if tuang_json_data %}
|
||||
<div class="camera-list">
|
||||
{% for camera, data in tuang_json_data.items() %}
|
||||
<div class="camera-item">
|
||||
<div class="camera-name">{{ camera }}</div>
|
||||
<div class="karung-count">{{ data.karung|default(0) }} karung</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-data">Tidak ada data karung tuang untuk hari ini</p>
|
||||
{% endif %}
|
||||
|
||||
<h3 style="margin-top: 30px;">Karung Masuk</h3>
|
||||
{% if masuk_json_data %}
|
||||
<div class="camera-list">
|
||||
{% for camera, data in masuk_json_data.items() %}
|
||||
<div class="camera-item">
|
||||
<div class="camera-name">{{ camera }}</div>
|
||||
<div class="karung-count">{{ data.karung|default(0) }} karung</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-data">Tidak ada data karung masuk untuk hari ini</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Historical Data from Database -->
|
||||
<details class="section historical-section" open>
|
||||
<summary class="historical-summary">
|
||||
<h2>🗄️ Data Historis - Dari Database</h2>
|
||||
<span class="historical-toggle-label"></span>
|
||||
</summary>
|
||||
<div class="historical-content">
|
||||
<p class="filter-note">
|
||||
Siklus aktif:
|
||||
{{ dashboard_settings.cycle_name }}.
|
||||
Rentang:
|
||||
{{ dashboard_settings.cycle_start }}
|
||||
sampai
|
||||
{{ dashboard_settings.cycle_end }}
|
||||
</p>
|
||||
|
||||
<div class="section">
|
||||
<div class="table-header">
|
||||
<h3 class="tuang">Karung Tuang</h3>
|
||||
{% if tuang_db_data %}
|
||||
<div class="action-buttons">
|
||||
<button class="edit-button" type="button" onclick="enableTableEditor('tuang')">Edit</button>
|
||||
<button id="tuang-save" class="edit-button save-button" form="tuang-edit-form" type="submit" disabled>Simpan</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if tuang_db_data %}
|
||||
<form id="tuang-edit-form" action="{{ url_for('edit_db_rows', db_name='tuang') }}" method="post">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Camera Name</th>
|
||||
<th>Tanggal</th>
|
||||
<th>Jumlah Karung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in tuang_db_data %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge badge-tuang">TUANG</span> {{ item.camera_name }}
|
||||
</td>
|
||||
<td>
|
||||
{{ item.display_date }}
|
||||
</td>
|
||||
<td>
|
||||
<input class="edit-input count tuang-counter-input" type="number" name="counter_value_{{ item.id }}" value="{{ item.counter_value }}" min="0" required disabled>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="no-data">Tidak ada data historis karung tuang</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="table-header">
|
||||
<h3 class="masuk">Karung Masuk</h3>
|
||||
{% if masuk_db_data %}
|
||||
<div class="action-buttons">
|
||||
<button class="edit-button" type="button" onclick="enableTableEditor('masuk')">Edit</button>
|
||||
<button id="masuk-save" class="edit-button save-button" form="masuk-edit-form" type="submit" disabled>Simpan</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if masuk_db_data %}
|
||||
<form id="masuk-edit-form" action="{{ url_for('edit_db_rows', db_name='masuk') }}" method="post">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Camera Name</th>
|
||||
<th>Tanggal</th>
|
||||
<th>Jumlah Karung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in masuk_db_data %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge badge-masuk">MASUK</span> {{ item.camera_name }}
|
||||
</td>
|
||||
<td>
|
||||
{{ item.date }}
|
||||
</td>
|
||||
<td>
|
||||
<input class="edit-input count masuk-counter-input" type="number" name="counter_value_{{ item.id }}" value="{{ item.counter_value }}" min="0" required disabled>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="no-data">Tidak ada data historis karung masuk</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Cycle Settings -->
|
||||
<div class="section">
|
||||
<h2>⚙️ Pengaturan Siklus</h2>
|
||||
<form class="settings-form" action="{{ url_for('update_dashboard_settings') }}" method="post">
|
||||
<div class="form-group">
|
||||
<label for="cycle_name">Cycle Name</label>
|
||||
<input id="cycle_name" class="settings-input" type="text" name="cycle_name" value="{{ dashboard_settings.cycle_name }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cycle_start">Cycle Start</label>
|
||||
<input id="cycle_start" class="settings-input" type="date" name="cycle_start" value="{{ cycle_start_picker_value }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cycle_end">Cycle End</label>
|
||||
<input id="cycle_end" class="settings-input" type="date" name="cycle_end" value="{{ cycle_end_picker_value }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="saldo_awal">Saldo Awal</label>
|
||||
<input id="saldo_awal" class="settings-input" type="number" name="saldo_awal" value="{{ dashboard_settings.saldo_awal }}" min="0" step="1" required>
|
||||
</div>
|
||||
<button class="settings-button" type="submit">Simpan Pengaturan</button>
|
||||
</form>
|
||||
<p class="settings-hint">Data historis hanya menampilkan data dalam rentang siklus.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function enableTableEditor(tableKey) {
|
||||
const counterInputs = document.querySelectorAll(`.${tableKey}-counter-input`);
|
||||
const saveButton = document.getElementById(`${tableKey}-save`);
|
||||
|
||||
counterInputs.forEach((counterInput) => {
|
||||
counterInput.disabled = false;
|
||||
});
|
||||
saveButton.disabled = false;
|
||||
|
||||
if (counterInputs.length > 0) {
|
||||
counterInputs[0].focus();
|
||||
counterInputs[0].select();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Reference in New Issue
Block a user