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();