update dashboard view to use cycle from dashboard cpsp and adjust the view for each kandang with selectable cycle for each kandang

This commit is contained in:
Alberto-Audrix committed 2026-09-02 09:06:35 +07:00
1 parent 014747fd36
commit 513fe2870f
11 files changed
+1660 -145

No files matched your search

+5
View File
@@ -55,5 +55,10 @@ TUANG_CUTOFF_TIME=17:00:00
# When Karung Masuk (In/Out) rolls to the next business day # When Karung Masuk (In/Out) rolls to the next business day
MASUK_CUTOFF_TIME=24:00:00 MASUK_CUTOFF_TIME=24:00:00
# Cycle API (maps K1 -> Kandang 1, K2 -> Kandang 2, etc.)
# Base URL without trailing slash; http:// is added automatically if omitted.
CYCLE_API_BASE_URL=http://192.168.192.106:5002
CYCLE_API_KEY=test-api-key
# Optional path for exported counter settings JSON (used by other services) # Optional path for exported counter settings JSON (used by other services)
# COUNTER_SETTINGS_FILE=counter_settings.json # COUNTER_SETTINGS_FILE=counter_settings.json
+111
View File
@@ -0,0 +1,111 @@
{
"ktc": {
"status": "ok",
"pulled_at": "2026-09-02T01:00:03Z",
"base_url": null,
"base_urls": [
"http://192.168.192.17:9000",
"http://192.168.192.15:9000"
],
"source_groups": [
[
"http://192.168.192.17:9000"
],
[
"http://192.168.192.15:9000"
]
],
"combine": "sum",
"hosts": [
{
"status": "ok",
"pulled_at": "2026-09-02T01:00:02Z",
"base_url": "http://192.168.192.17:9000",
"source_index": 1,
"error": null,
"live_sources": [
"K1 - Use"
],
"history_rows": 2
},
{
"status": "ok",
"pulled_at": "2026-09-02T01:00:02Z",
"base_url": "http://192.168.192.15:9000",
"source_index": 2,
"error": null,
"live_sources": [
"K2 - Use"
],
"history_rows": 2
}
],
"error": null,
"live_sources": [
"K1 - Use",
"K2 - Use"
],
"history_rows": 4,
"hosts_ok": 2,
"hosts_total": 2,
"groups_total": 2,
"source_key": "ktc"
},
"kpc": {
"status": "ok",
"pulled_at": "2026-09-02T01:00:03Z",
"base_url": null,
"base_urls": [
"http://192.168.192.17:7000",
"http://192.168.192.15:7000"
],
"source_groups": [
[
"http://192.168.192.17:7000"
],
[
"http://192.168.192.15:7000"
]
],
"combine": "sum",
"hosts": [
{
"status": "ok",
"pulled_at": "2026-09-02T01:00:03Z",
"base_url": "http://192.168.192.17:7000",
"source_index": 1,
"error": null,
"live_sources": [
"K1 - In",
"K1 - Out"
],
"history_rows": 6
},
{
"status": "ok",
"pulled_at": "2026-09-02T01:00:03Z",
"base_url": "http://192.168.192.15:7000",
"source_index": 2,
"error": null,
"live_sources": [
"K2 - In",
"K2 - Out"
],
"history_rows": 12
}
],
"error": null,
"live_sources": [
"K1 - In",
"K1 - Out",
"K2 - In",
"K2 - Out"
],
"history_rows": 18,
"hosts_ok": 2,
"hosts_total": 2,
"groups_total": 2,
"source_key": "kpc"
},
"updated_at": "2026-09-02T01:00:03Z"
}
+863 -81
View File
File diff suppressed because it is too large. Load diff
+217
View File
@@ -0,0 +1,217 @@
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
DISPLAY_DATE_FORMAT = '%d-%m-%Y'
K_CAMERA_PATTERN = re.compile(r'^K(\d+)\b', re.IGNORECASE)
KANDANG_NUMBER_PATTERN = re.compile(r'kandang\s*(\d+)\s*$', re.IGNORECASE)
def normalize_base_url(raw_url):
url = str(raw_url or '').strip().rstrip('/')
if not url:
return 'http://localhost:5002'
if not url.startswith(('http://', 'https://')):
url = f'http://{url}'
return url
def get_cycle_api_config():
base_url = normalize_base_url(os.environ.get('CYCLE_API_BASE_URL', 'http://localhost:5002'))
api_key = os.environ.get('CYCLE_API_KEY', '').strip()
return base_url, api_key
def is_cycle_api_configured():
_, api_key = get_cycle_api_config()
return bool(api_key)
def fetch_paginated_results(path, *, timeout=15):
base_url, api_key = get_cycle_api_config()
if not api_key:
raise RuntimeError('CYCLE_API_KEY is not configured.')
results = []
next_url = urllib.parse.urljoin(f'{base_url}/', path.lstrip('/'))
while next_url:
request = urllib.request.Request(next_url, method='GET')
request.add_header('Accept', 'application/json')
request.add_header('X-API-Key', api_key)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = json.loads(response.read().decode('utf-8'))
except urllib.error.HTTPError as error:
body = error.read().decode('utf-8', errors='replace')
raise RuntimeError(f'Cycle API request failed ({error.code}): {body}') from error
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
raise RuntimeError(f'Cycle API request failed: {error}') from error
if isinstance(payload, dict) and 'results' in payload:
page_results = payload.get('results') or []
if not isinstance(page_results, list):
raise RuntimeError('Cycle API paginated response is invalid.')
results.extend(page_results)
next_url = payload.get('next')
continue
if isinstance(payload, list):
results.extend(payload)
next_url = None
continue
raise RuntimeError('Cycle API response format is not supported.')
return results
def extract_k_index(source_name):
match = K_CAMERA_PATTERN.match(str(source_name or '').strip())
if not match:
return None
return int(match.group(1))
def extract_kandang_number(kandang_name):
match = KANDANG_NUMBER_PATTERN.search(str(kandang_name or '').strip())
if not match:
return None
return int(match.group(1))
def find_kandang_by_k_index(kandangs, k_index):
if k_index is None:
return None
for kandang in kandangs:
kandang_name = str(kandang.get('kandang_name', '')).strip()
number_match = KANDANG_NUMBER_PATTERN.search(kandang_name)
if number_match and int(number_match.group(1)) == k_index:
return kandang
return None
def format_dashboard_date(iso_date):
if not iso_date:
return ''
return datetime.strptime(iso_date, '%Y-%m-%d').strftime(DISPLAY_DATE_FORMAT)
def select_cycle_for_kandang(cycles, kandang_id):
kandang_cycles = [
cycle for cycle in cycles
if cycle.get('kandang') == kandang_id
]
if not kandang_cycles:
return None
active_cycles = [cycle for cycle in kandang_cycles if cycle.get('status') == 'active']
candidate_cycles = active_cycles or kandang_cycles
return max(candidate_cycles, key=lambda cycle: cycle.get('id', 0))
def format_cycle_name(cycle_start, cycle_end):
start = str(cycle_start or '').strip()
end = str(cycle_end or '').strip()
if start and end:
return f'{start} - {end}'
if start:
return f'{start} - akhir data'
if end:
return f'awal data - {end}'
return 'Siklus Aktif'
def build_cycle_settings(cycle, kandang):
kandang_name = kandang.get('kandang_name', f"Kandang {kandang.get('id')}")
cycle_start = format_dashboard_date(cycle.get('start_date'))
cycle_end = format_dashboard_date(cycle.get('end_date'))
return {
'id': cycle.get('id', ''),
'kandang_id': kandang.get('id'),
'k_index': extract_kandang_number(kandang_name),
'kandang_name': kandang_name,
'cycle_name': format_cycle_name(cycle_start, cycle_end),
'cycle_start': cycle_start,
'cycle_end': cycle_end,
'saldo_awal': cycle.get('feed_initial_balance') or 0,
'feed_initial_balance_date': format_dashboard_date(cycle.get('feed_initial_balance_date')),
'status': cycle.get('status', ''),
'flock': cycle.get('flock'),
}
def build_all_cycles_by_kandang(kandangs, cycles):
cycles_by_kandang = {}
for kandang in kandangs:
kandang_id = kandang.get('id')
if kandang_id is None:
continue
kandang_cycles = [
cycle for cycle in cycles
if cycle.get('kandang') == kandang_id
]
kandang_cycles.sort(key=lambda cycle: cycle.get('id', 0), reverse=True)
cycles_by_kandang[kandang_id] = [
build_cycle_settings(cycle, kandang)
for cycle in kandang_cycles
]
return cycles_by_kandang
def load_kandang_cycle_context():
kandangs = fetch_paginated_results('/api/v1/kandangs/')
cycles = fetch_paginated_results('/api/v1/cycles/')
kandang_by_k_index = {}
cycle_by_kandang_id = {}
kandang_cycles = []
for kandang in kandangs:
k_index = extract_kandang_number(kandang.get('kandang_name'))
if k_index is not None:
kandang_by_k_index[k_index] = kandang
cycle = select_cycle_for_kandang(cycles, kandang.get('id'))
if not cycle:
continue
cycle_settings = build_cycle_settings(cycle, kandang)
cycle_by_kandang_id[kandang['id']] = cycle_settings
kandang_cycles.append(cycle_settings)
kandang_cycles.sort(
key=lambda item: (item.get('k_index') is None, item.get('k_index') or 0, item.get('kandang_id') or 0)
)
return {
'kandangs': kandangs,
'kandang_by_k_index': kandang_by_k_index,
'cycle_by_kandang_id': cycle_by_kandang_id,
'kandang_cycles': kandang_cycles,
'all_cycles_by_kandang': build_all_cycles_by_kandang(kandangs, cycles),
}
def get_cycle_settings_for_camera(source_name, cycle_context):
k_index = extract_k_index(source_name)
if k_index is None:
return None
kandang = find_kandang_by_k_index(cycle_context.get('kandangs', []), k_index)
if not kandang:
return None
return cycle_context.get('cycle_by_kandang_id', {}).get(kandang['id'])
Binary file not shown.
BIN
View File
Binary file not shown.
+8 -2
View File
@@ -1,8 +1,14 @@
{ {
"K1-L1-in": { "K1 - In": {
"karung": 160
},
"K1 - Out": {
"karung": 0 "karung": 0
}, },
"K1-L1-out": { "K2 - In": {
"karung": 80
},
"K2 - Out": {
"karung": 0 "karung": 0
} }
} }
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"K1-L1-left": { "K1 - Use": {
"karung": 0 "karung": 0
}, },
"K1-L1-right": { "K2 - Use": {
"karung": 0 "karung": 0
} }
} }
+17 -1
View File
@@ -138,6 +138,22 @@ def build_source_camera_name(source_index, camera_suffix):
return f"K{source_index} - {camera_suffix}" return f"K{source_index} - {camera_suffix}"
def extract_history_calendar_date(row):
"""Use the actual activity date from KPC history, not the forward-looking counting_date."""
start_time = row.get("start_time")
if start_time:
try:
parsed = datetime.fromisoformat(str(start_time).replace("Z", "+00:00"))
if parsed.tzinfo is not None:
parsed = parsed.astimezone()
return parsed.date().isoformat()
except ValueError:
pass
counting_date = row.get("date") or row.get("counting_date")
return str(counting_date) if counting_date else None
def map_live_payload(payload, field_map, source_index): def map_live_payload(payload, field_map, source_index):
"""Map /api/current-counter fields to live JSON source entries.""" """Map /api/current-counter fields to live JSON source entries."""
mapped = {} mapped = {}
@@ -157,7 +173,7 @@ def map_history_rows(rows, field_map, source_index):
"""Map /api/history data rows to (camera_name, date, counter_value) tuples.""" """Map /api/history data rows to (camera_name, date, counter_value) tuples."""
mapped_rows = [] mapped_rows = []
for row in rows or []: for row in rows or []:
counting_date = row.get("date") or row.get("counting_date") counting_date = extract_history_calendar_date(row)
if not counting_date: if not counting_date:
continue continue
totals_by_camera = {} totals_by_camera = {}
+437 -59
View File
@@ -300,13 +300,70 @@
.hero-panel { .hero-panel {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
display: block;
padding: 0 0 20px;
border: 0;
background: transparent;
box-shadow: none;
}
.hero-content h1 {
margin-bottom: 0;
}
.dashboard-toolbar {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 14px 18px;
margin: 18px 0 24px;
padding: 16px 18px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-strong);
}
.toolbar-group {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 280px; gap: 6px;
gap: 24px; min-width: 180px;
align-items: stretch; flex: 1 1 180px;
padding: 28px; }
border-radius: var(--radius-lg);
box-shadow: var(--shadow), var(--glow); .toolbar-group-end {
margin-left: auto;
flex: 0 1 220px;
}
.toolbar-group label {
color: var(--muted);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.toolbar-select {
width: 100%;
min-height: 42px;
padding: 0 14px;
color: var(--text);
border: 1px solid var(--border);
border-radius: 12px;
background: var(--input-bg);
outline: none;
}
.toolbar-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18);
}
.cycle-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 14px;
margin-top: 16px;
} }
html[data-theme="futuristic"] .hero-panel { html[data-theme="futuristic"] .hero-panel {
@@ -408,7 +465,8 @@
z-index: 1; z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: flex-start;
gap: 12px;
min-height: 168px; min-height: 168px;
padding: 18px; padding: 18px;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -416,6 +474,11 @@
background: var(--surface-strong); background: var(--surface-strong);
} }
.selector-stack {
display: grid;
gap: 12px;
}
.theme-card label, .theme-card label,
.selected-cycle-label, .selected-cycle-label,
.metric-label { .metric-label {
@@ -487,6 +550,97 @@
gap: 14px; gap: 14px;
} }
.kandang-cycle-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.kandang-cycle-card {
display: grid;
gap: 8px;
}
.kandang-cycle-meta {
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.feed-balance-section {
margin-top: 22px;
padding-top: 22px;
border-top: 1px solid var(--border);
}
.feed-balance-section h3 {
margin-bottom: 14px;
color: var(--heading);
font-size: 18px;
}
.feed-balance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.feed-balance-card {
display: grid;
gap: 10px;
min-height: 120px;
padding: 18px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-soft);
}
.feed-balance-value {
color: var(--heading);
font-size: 28px;
font-weight: 700;
line-height: 1.1;
}
.feed-balance-meta {
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.feed-balance-status {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.feed-balance-status.match {
color: #166534;
background: rgba(34, 197, 94, 0.16);
}
.feed-balance-status.mismatch {
color: #991b1b;
background: rgba(239, 68, 68, 0.16);
}
.feed-balance-status.pending {
color: #92400e;
background: rgba(245, 158, 11, 0.16);
}
.feed-balance-date {
color: var(--heading);
font-size: 18px;
font-weight: 600;
}
.cycle-stat { .cycle-stat {
min-height: 104px; min-height: 104px;
padding: 18px; padding: 18px;
@@ -850,6 +1004,35 @@
text-align: center; text-align: center;
} }
html[data-theme="classic"] .dashboard-toolbar {
margin: 0 0 24px;
padding: 14px 16px;
border: 1px solid #dddddd;
border-radius: 8px;
background: #f8f9fa;
box-shadow: var(--shadow);
text-align: left;
}
html[data-theme="classic"] .toolbar-group label {
color: #2c3e50;
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
html[data-theme="classic"] .toolbar-select {
color: #333333;
border: 1px solid #cccccc;
border-radius: 5px;
background: #ffffff;
}
html[data-theme="classic"] .theme-card {
display: none;
}
html[data-theme="classic"] .hero-panel::after, html[data-theme="classic"] .hero-panel::after,
html[data-theme="classic"] .eyebrow, html[data-theme="classic"] .eyebrow,
html[data-theme="classic"] .hero-copy { html[data-theme="classic"] .hero-copy {
@@ -858,26 +1041,13 @@
html[data-theme="classic"] h1 { html[data-theme="classic"] h1 {
max-width: none; max-width: none;
margin-bottom: 30px; margin-bottom: 18px;
color: #333333; color: #333333;
font-size: 32px; font-size: 32px;
line-height: 1.2; line-height: 1.2;
letter-spacing: 0; letter-spacing: 0;
} }
html[data-theme="classic"] .theme-card {
align-items: flex-start;
min-height: auto;
margin: 0 0 30px auto;
padding: 15px;
border: 0;
border-left: 4px solid var(--accent);
background: #f8f9fa;
box-shadow: var(--shadow);
text-align: left;
}
html[data-theme="classic"] .theme-card label,
html[data-theme="classic"] .selected-cycle-label, html[data-theme="classic"] .selected-cycle-label,
html[data-theme="classic"] .metric-label { html[data-theme="classic"] .metric-label {
color: #2c3e50; color: #2c3e50;
@@ -887,17 +1057,6 @@
text-transform: none; text-transform: none;
} }
html[data-theme="classic"] .theme-hint {
margin-top: 6px;
}
html[data-theme="classic"] .theme-select {
color: #333333;
border: 1px solid #cccccc;
border-radius: 5px;
background: #ffffff;
}
html[data-theme="classic"] .selected-cycle { html[data-theme="classic"] .selected-cycle {
display: block; display: block;
margin: 0 0 30px; margin: 0 0 30px;
@@ -919,7 +1078,8 @@
display: none; display: none;
} }
html[data-theme="classic"] .selected-cycle-details { html[data-theme="classic"] .selected-cycle-details,
html[data-theme="classic"] .cycle-summary-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-top: 12px; margin-top: 12px;
gap: 12px; gap: 12px;
@@ -1149,11 +1309,19 @@
} }
.hero-panel, .hero-panel,
.selected-cycle { .selected-cycle,
.dashboard-toolbar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 20px; padding: 20px;
} }
.toolbar-group,
.toolbar-group-end {
min-width: 100%;
margin-left: 0;
flex-basis: 100%;
}
.selected-cycle-details, .selected-cycle-details,
.realtime-grid { .realtime-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1194,12 +1362,26 @@
Monitor realtime JSON counters, active cycle totals, balance movement, and database history from one responsive control surface. Monitor realtime JSON counters, active cycle totals, balance movement, and database history from one responsive control surface.
</p> </p>
</div> </div>
<div class="theme-card"> </header>
<div>
<label for="theme-select">Theme mode</label> <div class="dashboard-toolbar" aria-label="Dashboard filters">
<p class="theme-hint">Choose the dashboard style. Your selection is saved on this browser.</p> <div class="toolbar-group">
</div> <label for="kandang-select">Kandang</label>
<select id="theme-select" class="theme-select" aria-label="Select dashboard theme"> <select id="kandang-select" class="toolbar-select" aria-label="Select kandang">
{% for option in kandang_options %}
<option value="{{ option.k_index }}" data-kandang-id="{{ option.kandang_id }}" {% if option.k_index == selected_k_index %}selected{% endif %}>
{{ option.label }}
</option>
{% endfor %}
</select>
</div>
<div class="toolbar-group">
<label for="cycle-select">Cycle</label>
<select id="cycle-select" class="toolbar-select" aria-label="Select cycle"></select>
</div>
<div class="toolbar-group toolbar-group-end">
<label for="theme-select">Theme</label>
<select id="theme-select" class="toolbar-select" aria-label="Select dashboard theme">
<option value="futuristic">Futuristic</option> <option value="futuristic">Futuristic</option>
<option value="dark">Dark</option> <option value="dark">Dark</option>
<option value="simple">Simple</option> <option value="simple">Simple</option>
@@ -1207,26 +1389,24 @@
<option value="classic">Classic 20260603</option> <option value="classic">Classic 20260603</option>
</select> </select>
</div> </div>
</header> </div>
<section class="selected-cycle" aria-labelledby="active-cycle-title"> <section class="selected-cycle" aria-labelledby="active-cycle-title">
<div class="selected-cycle-header"> <div class="selected-cycle-header">
<span class="section-kicker">Active cycle</span> <span class="section-kicker">Active cycle</span>
<h2 id="active-cycle-title">Cycle Command Center</h2> <h2 id="active-cycle-title">Cycle Command Center</h2>
<p class="panel-subtitle">Active cycle data is loaded from the configured source and shown read-only.</p>
</div> </div>
<div class="selected-cycle-details"> {% if selected_cycle %}
<div class="cycle-summary-grid">
<div class="cycle-stat"> <div class="cycle-stat">
<span class="selected-cycle-label">Cycle Name</span> <span class="selected-cycle-label">Kandang</span>
<span class="selected-cycle-value">{{ dashboard_settings.cycle_name }}</span> <span class="selected-cycle-value">
{% if selected_cycle.k_index %}K{{ selected_cycle.k_index }}{% endif %}{% if selected_cycle.kandang_name %} · {{ selected_cycle.kandang_name }}{% endif %}
</span>
</div> </div>
<div class="cycle-stat"> <div class="cycle-stat">
<span class="selected-cycle-label">Range</span> <span class="selected-cycle-label">Status</span>
<span class="selected-cycle-value">{{ dashboard_settings.cycle_start or 'awal data' }} - {{ dashboard_settings.cycle_end or 'akhir data' }}</span> <span class="selected-cycle-value">{{ selected_cycle.status or '-' }}</span>
</div>
<div class="cycle-stat">
<span class="selected-cycle-label">Saldo Awal</span>
<span class="selected-cycle-value">{{ dashboard_settings.saldo_awal }}</span>
</div> </div>
<div class="cycle-stat"> <div class="cycle-stat">
<span class="selected-cycle-label">Timezone</span> <span class="selected-cycle-label">Timezone</span>
@@ -1241,13 +1421,16 @@
<span class="selected-cycle-value">{{ dashboard_settings.masuk_cutoff_time }}</span> <span class="selected-cycle-value">{{ dashboard_settings.masuk_cutoff_time }}</span>
</div> </div>
</div> </div>
{% else %}
<p class="no-data">Tidak ada data siklus kandang yang tersedia.</p>
{% endif %}
</section> </section>
<section class="metric-grid" aria-label="Dashboard summary"> <section class="metric-grid" aria-label="Dashboard summary">
<article class="metric-card neutral"> <article class="metric-card neutral">
<span class="metric-accent"></span> <span class="metric-accent"></span>
<span class="metric-label">Cycle Name</span> <span class="metric-label">Cycle Name</span>
<div class="metric-value text-value">{{ dashboard_settings.cycle_name }}</div> <div id="metric-cycle-name" class="metric-value text-value">{{ selected_cycle.cycle_name if selected_cycle else dashboard_settings.cycle_name }}</div>
</article> </article>
<article class="metric-card tuang"> <article class="metric-card tuang">
<span class="metric-accent"></span> <span class="metric-accent"></span>
@@ -1262,22 +1445,22 @@
<article class="metric-card tuang"> <article class="metric-card tuang">
<span class="metric-accent"></span> <span class="metric-accent"></span>
<span class="metric-label">Total Tuang Siklus</span> <span class="metric-label">Total Tuang Siklus</span>
<div class="metric-value">{{ tuang_db_total }}</div> <div id="metric-tuang-db-total" class="metric-value">{{ tuang_db_total }}</div>
</article> </article>
<article class="metric-card masuk"> <article class="metric-card masuk">
<span class="metric-accent"></span> <span class="metric-accent"></span>
<span class="metric-label">Total Masuk Siklus</span> <span class="metric-label">Total Masuk Siklus</span>
<div class="metric-value">{{ masuk_db_total }}</div> <div id="metric-masuk-db-total" class="metric-value">{{ masuk_db_total }}</div>
</article> </article>
<article class="metric-card balance"> <article class="metric-card balance">
<span class="metric-accent"></span> <span class="metric-accent"></span>
<span class="metric-label">Saldo Awal</span> <span class="metric-label">Saldo Awal</span>
<div class="metric-value">{{ dashboard_settings.saldo_awal }}</div> <div id="metric-saldo-awal" class="metric-value">{{ display_saldo_awal }}</div>
</article> </article>
<article class="metric-card balance"> <article class="metric-card balance">
<span class="metric-accent"></span> <span class="metric-accent"></span>
<span class="metric-label">Saldo Akhir</span> <span class="metric-label">Saldo Akhir</span>
<div class="metric-value">{{ saldo_akhir }}</div> <div id="metric-saldo-akhir" class="metric-value">{{ saldo_akhir }}</div>
</article> </article>
</section> </section>
@@ -1318,6 +1501,33 @@
<p id="masuk-json-empty" class="no-data" {% if masuk_json_data %}hidden{% endif %}>Tidak ada data karung masuk untuk hari ini</p> <p id="masuk-json-empty" class="no-data" {% if masuk_json_data %}hidden{% endif %}>Tidak ada data karung masuk untuk hari ini</p>
</div> </div>
</div> </div>
<div class="feed-balance-section" aria-labelledby="feed-balance-title">
<h3 id="feed-balance-title">Saldo Awal Pakan</h3>
<div id="feed-balance-list" class="feed-balance-grid" {% if not feed_initial_balances %}hidden{% endif %}>
{% for item in feed_initial_balances %}
<div class="feed-balance-card">
<span class="selected-cycle-label">{{ item.source }}</span>
<span class="feed-balance-value">{{ item.feed_initial_balance }} karung</span>
<span class="feed-balance-date">{{ item.feed_initial_balance_date or 'belum diatur' }}</span>
<span class="feed-balance-meta">
IoT {{ item.source }}:
{% if item.iot_value is not none %}{{ item.iot_value }} karung{% else %}belum ada data{% endif %}
</span>
{% if item.verification_status == 'match' %}
<span class="feed-balance-status match">Cocok dengan IoT</span>
{% elif item.verification_status == 'mismatch' %}
<span class="feed-balance-status mismatch">Tidak cocok dengan IoT</span>
{% elif item.verification_status == 'no_date' %}
<span class="feed-balance-status pending">Tanggal belum diatur</span>
{% else %}
<span class="feed-balance-status pending">Belum ada data IoT</span>
{% endif %}
</div>
{% endfor %}
</div>
<p id="feed-balance-empty" class="no-data" {% if feed_initial_balances %}hidden{% endif %}>Tidak ada data saldo awal pakan untuk kandang ini</p>
</div>
</section> </section>
<details class="section historical-section" open> <details class="section historical-section" open>
@@ -1326,7 +1536,7 @@
<span class="section-kicker">Database history</span> <span class="section-kicker">Database history</span>
<h2>Data Historis</h2> <h2>Data Historis</h2>
<p class="panel-subtitle"> <p class="panel-subtitle">
Siklus aktif: {{ dashboard_settings.cycle_name }}. Rentang: {{ dashboard_settings.cycle_start }} sampai {{ dashboard_settings.cycle_end }}. Siklus per kandang (K1 = Kandang 1, K2 = Kandang 2, dst). Data historis difilter sesuai siklus masing-masing kandang.
</p> </p>
</div> </div>
<span class="historical-toggle-label"></span> <span class="historical-toggle-label"></span>
@@ -1385,7 +1595,7 @@
<td> <td>
<span class="camera-cell"><span class="badge badge-masuk">MASUK</span> {{ item.source }}</span> <span class="camera-cell"><span class="badge badge-masuk">MASUK</span> {{ item.source }}</span>
</td> </td>
<td>{{ item.date }}</td> <td>{{ item.display_date }}</td>
<td>{{ item.counter_value }}</td> <td>{{ item.counter_value }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -1402,7 +1612,14 @@
</div> </div>
<script> <script>
const THEME_STORAGE_KEY = 'karung-dashboard-theme'; const THEME_STORAGE_KEY = 'karung-dashboard-theme';
const KANDANG_STORAGE_KEY = 'karung-dashboard-kandang';
const CYCLE_STORAGE_KEY = 'karung-dashboard-cycle';
const DASHBOARD_THEMES = ['futuristic', 'dark', 'simple', 'light', 'classic']; const DASHBOARD_THEMES = ['futuristic', 'dark', 'simple', 'light', 'classic'];
const CYCLES_BY_KANDANG = {{ cycles_by_kandang | tojson }};
const INITIAL_SELECTION = {
kIndex: {{ selected_k_index | tojson }},
cycleId: {{ selected_cycle_id | tojson }},
};
function setDashboardTheme(theme) { function setDashboardTheme(theme) {
const selectedTheme = DASHBOARD_THEMES.includes(theme) ? theme : 'futuristic'; const selectedTheme = DASHBOARD_THEMES.includes(theme) ? theme : 'futuristic';
@@ -1430,6 +1647,93 @@
}); });
} }
function getSelectedKandangId() {
const kandangSelect = document.getElementById('kandang-select');
if (!kandangSelect || kandangSelect.selectedOptions.length === 0) {
return null;
}
return kandangSelect.selectedOptions[0].dataset.kandangId || null;
}
function populateCycleOptions(kandangId, selectedCycleId) {
const cycleSelect = document.getElementById('cycle-select');
if (!cycleSelect) {
return;
}
const cycles = CYCLES_BY_KANDANG[String(kandangId)] || [];
cycleSelect.innerHTML = cycles.map((cycle) => {
const label = `${cycle.cycle_start || 'awal data'} - ${cycle.cycle_end || 'akhir data'}${cycle.status ? ` (${cycle.status})` : ''}`;
const selected = String(cycle.id) === String(selectedCycleId) ? 'selected' : '';
return `<option value="${cycle.id}" ${selected}>${escapeHtml(label)}</option>`;
}).join('');
cycleSelect.disabled = cycles.length === 0;
}
function navigateToSelection(kIndex, cycleId) {
if (!kIndex) {
return;
}
try {
localStorage.setItem(KANDANG_STORAGE_KEY, String(kIndex));
if (cycleId) {
localStorage.setItem(CYCLE_STORAGE_KEY, String(cycleId));
}
} catch (error) {
// Ignore storage errors.
}
const params = new URLSearchParams();
params.set('k_index', String(kIndex));
if (cycleId) {
params.set('cycle_id', String(cycleId));
}
window.location.search = params.toString();
}
function initializeKandangSelectors() {
const kandangSelect = document.getElementById('kandang-select');
const cycleSelect = document.getElementById('cycle-select');
if (!kandangSelect || !cycleSelect) {
return;
}
const urlParams = new URLSearchParams(window.location.search);
let kIndex = urlParams.get('k_index');
let cycleId = urlParams.get('cycle_id');
if (!kIndex) {
try {
kIndex = localStorage.getItem(KANDANG_STORAGE_KEY);
cycleId = localStorage.getItem(CYCLE_STORAGE_KEY);
} catch (error) {
kIndex = null;
cycleId = null;
}
}
if (kIndex && String(kIndex) !== String(INITIAL_SELECTION.kIndex)) {
navigateToSelection(kIndex, cycleId || INITIAL_SELECTION.cycleId);
return;
}
populateCycleOptions(getSelectedKandangId(), INITIAL_SELECTION.cycleId);
kandangSelect.addEventListener('change', () => {
const nextKandangId = getSelectedKandangId();
const cycles = CYCLES_BY_KANDANG[String(nextKandangId)] || [];
const nextCycleId = cycles.length ? cycles[0].id : null;
navigateToSelection(kandangSelect.value, nextCycleId);
});
cycleSelect.addEventListener('change', () => {
navigateToSelection(kandangSelect.value, cycleSelect.value);
});
}
function escapeHtml(value) { function escapeHtml(value) {
return String(value) return String(value)
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -1472,9 +1776,74 @@
emptyElement.hidden = true; emptyElement.hidden = true;
} }
function getFeedBalanceStatusLabel(status) {
if (status === 'match') {
return { className: 'match', label: 'Cocok dengan IoT' };
}
if (status === 'mismatch') {
return { className: 'mismatch', label: 'Tidak cocok dengan IoT' };
}
if (status === 'no_date') {
return { className: 'pending', label: 'Tanggal belum diatur' };
}
return { className: 'pending', label: 'Belum ada data IoT' };
}
function renderFeedInitialBalances(items) {
const listElement = document.getElementById('feed-balance-list');
const emptyElement = document.getElementById('feed-balance-empty');
if (!listElement || !emptyElement) {
return;
}
if (!items || items.length === 0) {
listElement.innerHTML = '';
listElement.hidden = true;
emptyElement.hidden = false;
return;
}
listElement.innerHTML = items.map((item) => {
const sourceName = escapeHtml(item.source || '');
const balanceValue = Number(item.feed_initial_balance || 0);
const balanceDate = escapeHtml(item.feed_initial_balance_date || 'belum diatur');
const iotValue = item.iot_value;
const iotLabel = iotValue === null || iotValue === undefined
? 'belum ada data'
: `${Number(iotValue)} karung`;
const status = getFeedBalanceStatusLabel(item.verification_status);
return `
<div class="feed-balance-card">
<span class="selected-cycle-label">${sourceName}</span>
<span class="feed-balance-value">${balanceValue} karung</span>
<span class="feed-balance-date">${balanceDate}</span>
<span class="feed-balance-meta">IoT ${sourceName}: ${escapeHtml(iotLabel)}</span>
<span class="feed-balance-status ${status.className}">${status.label}</span>
</div>
`;
}).join('');
listElement.hidden = false;
emptyElement.hidden = true;
}
async function refreshRealtimeJsonData() { async function refreshRealtimeJsonData() {
try { try {
const response = await fetch('{{ url_for("get_active_cycle_sections") }}', { const kandangSelect = document.getElementById('kandang-select');
const cycleSelect = document.getElementById('cycle-select');
const params = new URLSearchParams();
if (kandangSelect && kandangSelect.value) {
params.set('k_index', kandangSelect.value);
}
if (cycleSelect && cycleSelect.value) {
params.set('cycle_id', cycleSelect.value);
}
const query = params.toString();
const endpoint = query
? `{{ url_for("get_active_cycle_sections") }}?${query}`
: '{{ url_for("get_active_cycle_sections") }}';
const response = await fetch(endpoint, {
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
}, },
@@ -1486,10 +1855,17 @@
const payload = await response.json(); const payload = await response.json();
const todaySection = payload.data.sections.today; const todaySection = payload.data.sections.today;
const selectedCycle = payload.data.cycle || {};
const totals = payload.data.totals || {};
document.getElementById('current-json-date').textContent = todaySection.date_label || formatDisplayDate(todaySection.date); document.getElementById('current-json-date').textContent = todaySection.date_label || formatDisplayDate(todaySection.date);
document.getElementById('tuang-json-total').textContent = todaySection.totals.tuang; document.getElementById('tuang-json-total').textContent = todaySection.totals.tuang;
document.getElementById('masuk-json-total').textContent = todaySection.totals.masuk; document.getElementById('masuk-json-total').textContent = todaySection.totals.masuk;
document.getElementById('metric-cycle-name').textContent = selectedCycle.name || '';
document.getElementById('metric-tuang-db-total').textContent = totals.tuang_db_total ?? 0;
document.getElementById('metric-masuk-db-total').textContent = totals.masuk_db_total ?? 0;
document.getElementById('metric-saldo-awal').textContent = totals.saldo_awal ?? 0;
document.getElementById('metric-saldo-akhir').textContent = totals.saldo_akhir ?? 0;
renderJsonCounterList( renderJsonCounterList(
document.getElementById('tuang-json-list'), document.getElementById('tuang-json-list'),
@@ -1501,12 +1877,14 @@
document.getElementById('masuk-json-empty'), document.getElementById('masuk-json-empty'),
todaySection.masuk todaySection.masuk
); );
renderFeedInitialBalances(payload.data.feed_initial_balances || []);
} catch (error) { } catch (error) {
console.error('Gagal memperbarui data realtime JSON.', error); console.error('Gagal memperbarui data realtime JSON.', error);
} }
} }
initializeThemeSelector(); initializeThemeSelector();
initializeKandangSelectors();
refreshRealtimeJsonData(); refreshRealtimeJsonData();
setInterval(refreshRealtimeJsonData, 3000); setInterval(refreshRealtimeJsonData, 3000);
</script> </script>