diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c0f720c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+docker/**/*.sh text eol=lf
+docker/crontab text eol=lf
+docker/Dockerfile* text eol=lf
+docker/nginx.conf text eol=lf
+docker/entrypoint-*.sh text eol=lf
+
diff --git a/DOCKER.md b/DOCKER.md
index a68b7bb..6f321a9 100644
--- a/DOCKER.md
+++ b/DOCKER.md
@@ -36,7 +36,7 @@ Deploy **dashboard-cpsp-executive** (HQ) with Docker Compose. Same layout as sit
- Docker 20.10+
- Docker Compose v2+
-- Ports available: **80** (UI), **18001** (direct API), **15433** (Postgres on localhost)
+- Ports available: **80** (UI; set `FRONTEND_PORT=8080` in `.env` if busy), **18001** (direct API), **15433** (Postgres on localhost)
```bash
docker --version
diff --git a/README.md b/README.md
index 751f6d0..5973528 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ Same Compose layout as the site app. On the HQ host: UI **:80**, API debug **:18
```bash
cp docker/.env.example .env
# set SECRET_KEY, DB_PASSWORD, BOOTSTRAP_ADMIN_PASSWORD
+# optional: FRONTEND_PORT=8080 if host :80 is busy
docker compose up -d --build
```
diff --git a/backend/apps/farms/services/site_close.py b/backend/apps/farms/services/site_close.py
index ff0dcd9..4c41396 100644
--- a/backend/apps/farms/services/site_close.py
+++ b/backend/apps/farms/services/site_close.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
-import requests
+import httpx
from apps.farms.models import Cycle
@@ -32,11 +32,11 @@ def push_close_decision(cycle: Cycle, *, approved: bool) -> None:
payload["end_date"] = cycle.end_date.isoformat()
try:
- response = requests.post(
+ response = httpx.post(
url,
json=payload,
headers={"X-API-Key": city.api_key, "Content-Type": "application/json"},
- timeout=30,
+ timeout=30.0,
)
if response.status_code >= 400:
raise SiteClosePushError(
@@ -44,6 +44,6 @@ def push_close_decision(cycle: Cycle, *, approved: bool) -> None:
)
except SiteClosePushError:
raise
- except requests.RequestException as exc:
+ except httpx.HTTPError as exc:
logger.exception("Push close decision failed for cycle %s", cycle.pk)
raise SiteClosePushError("Gagal kirim keputusan ke situs — coba lagi") from exc
diff --git a/components/CycleTimeline.tsx b/components/CycleTimeline.tsx
index b19aa31..10f4e7f 100644
--- a/components/CycleTimeline.tsx
+++ b/components/CycleTimeline.tsx
@@ -32,6 +32,12 @@ function phasesFor(totalDays: number): Phase[] {
const statusConfig = {
active: { label: 'Active', bg: 'bg-red-100', text: 'text-red-800', icon: 'fa-running' },
+ pending_close: {
+ label: 'Pending Close',
+ bg: 'bg-amber-100',
+ text: 'text-amber-800',
+ icon: 'fa-hourglass-half',
+ },
closed: {
label: 'Completed',
bg: 'bg-green-100',
diff --git a/components/__tests__/UserManagementPage.test.tsx b/components/__tests__/UserManagementPage.test.tsx
index 187d571..5760589 100644
--- a/components/__tests__/UserManagementPage.test.tsx
+++ b/components/__tests__/UserManagementPage.test.tsx
@@ -138,7 +138,9 @@ describe('UserManagementPage', () => {
it('resets password', async () => {
render();
await screen.findByText('gm1');
- fireEvent.click(screen.getAllByRole('button', { name: /Reset password/i })[0]);
+ const resetBtn = screen.getAllByRole('button', { name: /Reset password/i })[0];
+ expect(resetBtn).toBeTruthy();
+ fireEvent.click(resetBtn!);
fireEvent.change(screen.getByPlaceholderText(/Password baru/i), {
target: { value: 'reset-me' },
});
diff --git a/components/counting/DataManagementPage.tsx b/components/counting/DataManagementPage.tsx
index 789dbc4..018a9f4 100644
--- a/components/counting/DataManagementPage.tsx
+++ b/components/counting/DataManagementPage.tsx
@@ -116,7 +116,9 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
const kpiByDate = new Map(kpis.map((k) => [k.date, k]));
const lastCycleDay = Math.max(0, (selectedCycle.total_days || 1) - 1);
- const endDay = cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date);
+ const endDay = selectedCycle.end_date
+ ? cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date)
+ : lastCycleDay;
const calendarDay = cycleDayForDate(selectedCycle.start_date, todayIso());
const maxDay = Math.min(calendarDay, lastCycleDay, endDay);
const today = todayIso();
diff --git a/components/dashboard/ReportPreviewOverlay.tsx b/components/dashboard/ReportPreviewOverlay.tsx
index 9667ec0..15fa9b6 100644
--- a/components/dashboard/ReportPreviewOverlay.tsx
+++ b/components/dashboard/ReportPreviewOverlay.tsx
@@ -65,7 +65,9 @@ const ReportPreviewOverlay: React.FC = ({ open, reportType, onClose }) =>
const endCycleDay = selectedCycle
? Math.min(
selectedCycle.total_days,
- cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date)
+ selectedCycle.end_date
+ ? cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date)
+ : Math.max(0, (selectedCycle.total_days || 1) - 1)
)
: 0;
const effectiveUpToDay = isEndCycle ? endCycleDay : draftDay;
diff --git a/docker-compose.yml b/docker-compose.yml
index 013f622..c4afa13 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -144,7 +144,8 @@ services:
container_name: dashboard-cpsp-executive-frontend
restart: unless-stopped
ports:
- - "80:80"
+ # Override with FRONTEND_PORT in .env when host :80 is busy (e.g. local Windows).
+ - "${FRONTEND_PORT:-80}:80"
depends_on:
api:
condition: service_healthy
@@ -152,7 +153,7 @@ services:
- dashboard-cpsp-executive-network
healthcheck:
test:
- ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"]
+ ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:80/health"]
interval: 30s
timeout: 3s
retries: 3
diff --git a/docker/.env.example b/docker/.env.example
index 942d8d6..3a5c64e 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -13,6 +13,9 @@ ALLOWED_HOSTS=localhost,127.0.0.1,api,frontend,executive.local
CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1,http://executive.local
CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1,http://executive.local
+# Host port for the UI (default 80). Use 8080 if :80 is already taken.
+FRONTEND_PORT=80
+
# Production admin (bootstrap_admin — no demo data)
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n
diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api
index c938d75..be1ba2f 100644
--- a/docker/Dockerfile.api
+++ b/docker/Dockerfile.api
@@ -22,10 +22,12 @@ COPY backend/ .
COPY docker/entrypoint-api.sh docker/entrypoint-cron.sh docker/karung_sync_loop.sh /app/
COPY docker/crontab /etc/cron.d/executive
+# /etc/cron.d files include a username field; system cron reads them directly
+# (do not install with `crontab`, which expects user-crontab format).
RUN chmod +x /app/entrypoint-api.sh /app/entrypoint-cron.sh /app/karung_sync_loop.sh \
&& mkdir -p /app/data /app/media /app/staticfiles \
&& chmod 0644 /etc/cron.d/executive \
- && crontab /etc/cron.d/executive
+ && touch /var/log/cron.log
EXPOSE 8000
diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web
index 3e98fe0..b0b1a61 100644
--- a/docker/Dockerfile.web
+++ b/docker/Dockerfile.web
@@ -20,6 +20,6 @@ COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
- CMD wget --quiet --tries=1 --spider http://localhost:80/health || exit 1
+ CMD wget --quiet --tries=1 --spider http://127.0.0.1:80/health || exit 1
CMD ["nginx", "-g", "daemon off;"]
diff --git a/docker/entrypoint-cron.sh b/docker/entrypoint-cron.sh
index 6abc8e8..d72b873 100644
--- a/docker/entrypoint-cron.sh
+++ b/docker/entrypoint-cron.sh
@@ -7,6 +7,7 @@ printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|CHICKEN_COUNTING_|DA
# Render publish-time cron from DASHBOARD_PUBLISH_* (default 17:00).
# Edge syncs are OFF by default at HQ (mirror via city-sites); commands no-op when disabled.
+# File lives in /etc/cron.d (requires username field) — do not feed it to `crontab`.
PUBLISH_HOUR="${DASHBOARD_PUBLISH_HOUR:-17}"
PUBLISH_MINUTE="${DASHBOARD_PUBLISH_MINUTE:-0}"
CRON_FILE=/etc/cron.d/executive
@@ -20,9 +21,10 @@ CRON_FILE=/etc/cron.d/executive
echo ""
echo "# Daily publish — optional karung sync then KPI rollups at configured cutoff."
echo "${PUBLISH_MINUTE} ${PUBLISH_HOUR} * * * root cd /app && /usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1 && /usr/local/bin/python manage.py recompute_kpi_rollups >> /var/log/cron.log 2>&1"
+ echo ""
} > "$CRON_FILE"
chmod 0644 "$CRON_FILE"
-crontab "$CRON_FILE"
+touch /var/log/cron.log
if [ "${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}" = "true" ] || [ "${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}" = "True" ]; then
/app/karung_sync_loop.sh &
diff --git a/utils/__tests__/buildInMemoryReport.test.ts b/utils/__tests__/buildInMemoryReport.test.ts
index d282eaf..d76d532 100644
--- a/utils/__tests__/buildInMemoryReport.test.ts
+++ b/utils/__tests__/buildInMemoryReport.test.ts
@@ -14,7 +14,7 @@ const cycle: Cycle = {
chick_in_weight: 45,
doc_in_count: 20000,
feed_initial_balance: 0,
- status: 'gm',
+ status: 'active',
created_at: '',
updated_at: '',
};
diff --git a/utils/__tests__/countingAccuracyWarning.test.ts b/utils/__tests__/countingAccuracyWarning.test.ts
index 022a9c4..8079117 100644
--- a/utils/__tests__/countingAccuracyWarning.test.ts
+++ b/utils/__tests__/countingAccuracyWarning.test.ts
@@ -14,7 +14,7 @@ const cycle = {
chick_in_weight: 45,
doc_in_count: 1000,
feed_initial_balance: 0,
- status: 'gm',
+ status: 'active',
created_at: '',
updated_at: '',
} satisfies Cycle;
diff --git a/utils/buildInMemoryReport.ts b/utils/buildInMemoryReport.ts
index 590173e..8d38390 100644
--- a/utils/buildInMemoryReport.ts
+++ b/utils/buildInMemoryReport.ts
@@ -50,7 +50,9 @@ export function buildInMemoryReport(input: BuildInMemoryReportInput): DashboardR
// Day numbering stays 0-based (day 0 = start_date), but the report must never
// run past the cycle end date. lastDay = start_date → end_date in days, which
// is total_days - 1 for a contiguous cycle.
- const lastDay = cycleDayForDate(cycle.start_date, cycle.end_date);
+ const lastDay = cycle.end_date
+ ? cycleDayForDate(cycle.start_date, cycle.end_date)
+ : Math.max(0, (cycle.total_days || 1) - 1);
const endCycleUpTo = Math.min(cycle.total_days, lastDay);
const defaultUpTo =
input.reportType === 'end_cycle'
@@ -202,7 +204,7 @@ export function buildInMemoryReport(input: BuildInMemoryReportInput): DashboardR
return {
cycleId: String(cycle.id),
cycleStart: cycle.start_date,
- cycleEnd: cycle.end_date,
+ cycleEnd: cycle.end_date ?? '',
kandangId: input.kandangId,
kandangName: input.kandangName,
area: input.area,
diff --git a/utils/countingAccuracyWarning.ts b/utils/countingAccuracyWarning.ts
index da83dc6..59b8548 100644
--- a/utils/countingAccuracyWarning.ts
+++ b/utils/countingAccuracyWarning.ts
@@ -38,7 +38,9 @@ function stockAkhirByDate(
const manualByDate = new Map(manualInputs.map((m) => [m.date, m]));
const lastCycleDay = Math.max(0, (cycle.total_days || 1) - 1);
- const endDay = cycleDayForDate(cycle.start_date, cycle.end_date);
+ const endDay = cycle.end_date
+ ? cycleDayForDate(cycle.start_date, cycle.end_date)
+ : lastCycleDay;
const maxDay = Math.min(lastCycleDay, endDay);
const result = new Map();