remove wagtail and admin website, remove edit option, from hitung ayam, add create flock to settings

This commit is contained in:
Alberto-Audrix committed 2026-08-31 16:00:44 +07:00
1 parent 74dd3faf60
commit 30a736641f
39 files changed
+634 -819

No files matched your search

+3 -1
View File
@@ -58,7 +58,7 @@ docker compose up -d --build
docker compose exec api python manage.py bootstrap_admin
```
Uses `BOOTSTRAP_ADMIN_USER` / `BOOTSTRAP_ADMIN_PASSWORD` from `.env` (default in `docker/.env.example`: `admin` / `Pr04dm1n`). Then log in and create sites, kandang, and cycles in Pengaturan.
Uses `BOOTSTRAP_ADMIN_USER` / `BOOTSTRAP_ADMIN_PASSWORD` from `.env` (default in `docker/.env.example`: `admin` / `Pr04dm1n`). Optionally set `BOOTSTRAP_STAFF_USER` / `BOOTSTRAP_STAFF_PASSWORD` to create a staff login at the same time. Then log in and create sites, kandang, and cycles in Pengaturan.
**Dev / demo** (Sukawarna sample cycles):
@@ -108,6 +108,8 @@ Important production values:
| `KARUNG_WEB_ADMIN_BASE_URL` | External karung service; default `http://host.docker.internal:5000` reaches the host from containers |
| `BOOTSTRAP_ADMIN_USER` | Superadmin username for `bootstrap_admin` (default `admin`) |
| `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) |
| `BOOTSTRAP_STAFF_USER` | Optional staff username; leave empty to skip staff creation |
| `BOOTSTRAP_STAFF_PASSWORD` | Staff password (required when `BOOTSTRAP_STAFF_USER` is set) |
| `BOOTSTRAP_API_KEY` | Optional fixed API key for scripts (hashed at rest) |
Wagtail admin is proxied at **http://localhost:5002/admin/** (through Nginx → Django).
+4 -3
View File
@@ -20,9 +20,6 @@ DB_NAME=db.sqlite3
# DB_HOST=database
# DB_PORT=5432
WAGTAIL_SITE_NAME=New Dashboard
WAGTAILADMIN_BASE_URL=http://localhost:8000
API_PAGE_SIZE=50
SESSION_COOKIE_SECURE=False
CSRF_COOKIE_SECURE=False
@@ -34,6 +31,10 @@ BOOTSTRAP_API_KEY=
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=
# Optional staff account (leave BOOTSTRAP_STAFF_USER empty to skip)
BOOTSTRAP_STAFF_USER=
BOOTSTRAP_STAFF_PASSWORD=
# karung-web-admin (external)
KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
-33
View File
@@ -1,33 +0,0 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from apps.accounts.models import ApiKey, User
@admin.register(User)
class UserAdmin(DjangoUserAdmin):
ordering = ("user_name",)
list_display = ("user_name", "status", "created_at")
search_fields = ("user_name",)
fieldsets = (
(None, {"fields": ("user_name", "password")}),
("Permissions", {"fields": ("status", "groups", "user_permissions")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("user_name", "password1", "password2", "status"),
},
),
)
filter_horizontal = ("groups", "user_permissions")
@admin.register(ApiKey)
class ApiKeyAdmin(admin.ModelAdmin):
list_display = ("name", "prefix", "user", "is_active", "last_used_at", "created_at")
list_filter = ("is_active",)
search_fields = ("name", "prefix", "user__user_name")
readonly_fields = ("prefix", "key_hash", "last_used_at", "created_at", "updated_at")
-8
View File
@@ -1,8 +0,0 @@
from django.contrib import admin
from apps.farms.models import Cycle, Flock, Kandang, Site
admin.site.register(Site)
admin.site.register(Kandang)
admin.site.register(Flock)
admin.site.register(Cycle)
@@ -0,0 +1,50 @@
from django.db import migrations, models
import django.db.models.deletion
def backfill_flock_kandang(apps, schema_editor):
Flock = apps.get_model("farms", "Flock")
for flock in Flock.objects.select_related("cycle").iterator():
if flock.cycle_id and flock.kandang_id is None:
flock.kandang_id = flock.cycle.kandang_id
flock.save(update_fields=["kandang_id"])
class Migration(migrations.Migration):
dependencies = [
("farms", "0008_cycle_feed_initial_balance"),
]
operations = [
migrations.AddField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.RunPython(backfill_flock_kandang, migrations.RunPython.noop),
migrations.AlterField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="flocks",
to="farms.cycle",
),
),
]
@@ -0,0 +1,32 @@
from django.db import migrations, models
import django.db.models.deletion
def remove_orphan_flocks(apps, schema_editor):
Flock = apps.get_model("farms", "Flock")
Flock.objects.filter(cycle_id__isnull=True).delete()
class Migration(migrations.Migration):
atomic = False
dependencies = [
("farms", "0009_flock_belongs_to_kandang"),
]
operations = [
migrations.RunPython(remove_orphan_flocks, migrations.RunPython.noop),
migrations.RemoveField(
model_name="flock",
name="kandang",
),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.cycle",
),
),
]
@@ -0,0 +1,45 @@
from django.db import migrations, models
import django.db.models.deletion
def backfill_flock_kandang_from_cycle(apps, schema_editor):
Flock = apps.get_model("farms", "Flock")
for flock in Flock.objects.select_related("cycle").iterator():
if flock.cycle_id and flock.kandang_id is None:
flock.kandang_id = flock.cycle.kandang_id
flock.save(update_fields=["kandang_id"])
class Migration(migrations.Migration):
atomic = False
dependencies = [
("farms", "0010_restore_flock_cycle_erd"),
]
operations = [
migrations.AddField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.RunPython(backfill_flock_kandang_from_cycle, migrations.RunPython.noop),
migrations.AlterField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.RemoveField(
model_name="flock",
name="cycle",
),
]
+2 -34
View File
@@ -2,13 +2,10 @@ from datetime import date
from django.core.exceptions import ValidationError
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.snippets.models import register_snippet
from apps.accounts.models import User
@register_snippet
class Site(models.Model):
site_id = models.BigAutoField(primary_key=True)
site_name = models.CharField(max_length=30)
@@ -16,11 +13,6 @@ class Site(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("site_name"),
FieldPanel("user"),
]
class Meta:
db_table = "sites"
ordering = ["site_name"]
@@ -29,7 +21,6 @@ class Site(models.Model):
return self.site_name
@register_snippet
class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True)
kandang_name = models.CharField(max_length=30)
@@ -37,11 +28,6 @@ class Kandang(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("kandang_name"),
FieldPanel("site"),
]
class Meta:
db_table = "kandang"
ordering = ["kandang_name"]
@@ -50,23 +36,17 @@ class Kandang(models.Model):
return self.kandang_name
@register_snippet
class Flock(models.Model):
flock_id = models.BigAutoField(primary_key=True)
flock_name = models.CharField(max_length=30)
cycle = models.ForeignKey(
"Cycle",
kandang = models.ForeignKey(
Kandang,
on_delete=models.CASCADE,
related_name="flocks",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("flock_name"),
FieldPanel("cycle"),
]
class Meta:
db_table = "flock"
ordering = ["flock_name"]
@@ -75,7 +55,6 @@ class Flock(models.Model):
return self.flock_name
@register_snippet
class Cycle(models.Model):
STATUS_ACTIVE = "active"
STATUS_CLOSED = "closed"
@@ -98,17 +77,6 @@ class Cycle(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("kandang"),
FieldPanel("total_days"),
FieldPanel("start_date"),
FieldPanel("end_date"),
FieldPanel("doc_in_weight"),
FieldPanel("doc_in_count"),
FieldPanel("feed_initial_balance"),
FieldPanel("status"),
]
class Meta:
db_table = "cycles"
ordering = ["-start_date"]
+1 -24
View File
@@ -21,15 +21,13 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Flock
fields = ["id", "flock_name", "cycle", "created_at", "updated_at"]
fields = ["id", "flock_name", "kandang", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
kandang_name = serializers.CharField(source="kandang.kandang_name", read_only=True)
flock = serializers.SerializerMethodField()
flock_name = serializers.SerializerMethodField()
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
current_day = serializers.IntegerField(read_only=True)
visible_through_date = serializers.DateField(read_only=True)
@@ -40,8 +38,6 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"id",
"kandang",
"kandang_name",
"flock",
"flock_name",
"total_days",
"current_day",
"visible_through_date",
@@ -57,26 +53,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
read_only_fields = [
"id",
"kandang_name",
"flock",
"flock_name",
"feed_initial_balance",
"created_at",
"updated_at",
]
def _first_flock(self, obj):
return obj.flocks.order_by("flock_id").first()
def get_flock(self, obj):
flock = self._first_flock(obj)
return flock.pk if flock is not None else None
def get_flock_name(self, obj):
flock = self._first_flock(obj)
return flock.flock_name if flock is not None else None
def create(self, validated_data):
kandang = validated_data["kandang"]
cycle = Cycle.objects.create(**validated_data)
Flock.objects.create(flock_name=kandang.kandang_name[:30], cycle=cycle)
return cycle
+52 -51
View File
@@ -10,12 +10,37 @@ from apps.farms.models import Cycle, Flock, Kandang, Site
class FarmAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="tester", password="secret")
self.admin = User.objects.create_user(
user_name="admin",
password="secret",
status=User.STATUS_SUPERADMIN,
)
self.client = APIClient()
self.client.force_authenticate(self.user)
self.site = Site.objects.create(site_name="Sukawarna", user=self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
def test_create_cycle_auto_creates_flock_and_returns_erd_fields(self):
def test_create_flock_under_kandang_requires_superadmin(self):
self.client.force_authenticate(self.admin)
response = self.client.post(
"/api/v1/flocks/",
{"flock_name": "Lantai 1", "kandang": self.kandang.pk},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["flock_name"], "Lantai 1")
self.assertEqual(response.data["kandang"], self.kandang.pk)
def test_staff_cannot_create_flock(self):
self.client.force_authenticate(self.user)
response = self.client.post(
"/api/v1/flocks/",
{"flock_name": "Lantai 1", "kandang": self.kandang.pk},
format="json",
)
self.assertEqual(response.status_code, 403, response.data)
def test_create_cycle_without_flock(self):
self.client.force_authenticate(self.user)
start = date.today()
response = self.client.post(
"/api/v1/cycles/",
@@ -31,56 +56,32 @@ class FarmAPITests(TestCase):
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["chick_in_weight"], 42)
self.assertEqual(response.data["kandang_name"], "Kandang 1")
self.assertEqual(response.data["flock_name"], "Kandang 1")
self.assertIsNotNone(response.data["flock"])
cycle = Cycle.objects.get(pk=response.data["id"])
flock = Flock.objects.get(pk=response.data["flock"])
self.assertEqual(cycle.doc_in_weight, 42)
self.assertEqual(flock.cycle_id, cycle.pk)
self.assertNotIn("flock", response.data)
self.assertNotIn("flock_name", response.data)
def test_list_cycles_by_kandang(self):
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle)
response = self.client.get("/api/v1/cycles/", {"kandang_id": self.kandang.pk})
self.assertEqual(response.status_code, 200)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["flock"], flock.pk)
self.assertEqual(results[0]["kandang_name"], "Kandang 1")
self.assertEqual(results[0]["flock_name"], "Flock A")
def test_flock_persists_across_cycles(self):
flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
start = date.today()
for offset in (0, 40):
Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start + timedelta(days=offset),
end_date=start + timedelta(days=offset + 34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.assertEqual(Flock.objects.filter(kandang=self.kandang).count(), 1)
self.assertEqual(flock.kandang_id, self.kandang.pk)
def test_list_flocks_by_cycle(self):
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle)
other_cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
Flock.objects.create(flock_name="Flock B", cycle=other_cycle)
response = self.client.get("/api/v1/flocks/", {"cycle_id": cycle.pk})
def test_list_flocks_by_kandang(self):
self.client.force_authenticate(self.user)
Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
Flock.objects.create(flock_name="Lantai 2", kandang=self.kandang)
other_kandang = Kandang.objects.create(kandang_name="Kandang 2", site=self.site)
Flock.objects.create(flock_name="Other", kandang=other_kandang)
response = self.client.get("/api/v1/flocks/", {"kandang_id": self.kandang.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual([r["id"] for r in results], [flock.pk])
self.assertEqual(len(results), 2)
+6 -5
View File
@@ -56,19 +56,20 @@ class KandangViewSet(viewsets.ModelViewSet):
class FlockViewSet(viewsets.ModelViewSet):
queryset = Flock.objects.all()
queryset = Flock.objects.select_related("kandang").all()
serializer_class = FlockSerializer
permission_classes = [IsSuperAdminForWrite]
def get_queryset(self):
qs = super().get_queryset()
cycle_id = self.request.query_params.get("cycle_id")
if cycle_id:
qs = qs.filter(cycle_id=cycle_id)
kandang_id = self.request.query_params.get("kandang_id")
if kandang_id:
qs = qs.filter(kandang_id=kandang_id)
return qs
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related("kandang").prefetch_related("flocks").all()
queryset = Cycle.objects.select_related("kandang").all()
serializer_class = CycleSerializer
def get_queryset(self):
@@ -5,62 +5,119 @@ from django.db import transaction
from apps.accounts.models import ApiKey, User
def ensure_bootstrap_user(
*,
user_name: str,
password: str,
status: str,
is_staff: bool,
is_superuser: bool,
force_password: bool,
stdout,
) -> User:
user, created = User.objects.get_or_create(
user_name=user_name,
defaults={
"status": status,
"is_staff": is_staff,
"is_superuser": is_superuser,
},
)
if not created and user.status != status:
raise CommandError(
f"User {user_name!r} already exists with status {user.status!r}; "
f"cannot bootstrap as {status!r}"
)
should_set_password = created or not user.has_usable_password() or force_password
if should_set_password:
user.set_password(password)
user.status = status
user.is_staff = is_staff
user.is_superuser = is_superuser
user.save()
action = "Created" if created else "Updated password for"
stdout.write(f"{action} {status} user {user_name}")
else:
user.status = status
user.is_staff = is_staff
user.is_superuser = is_superuser
user.save(update_fields=["status", "is_staff", "is_superuser", "updated_at"])
stdout.write(f"User {user_name} already exists (password unchanged)")
return user
class Command(BaseCommand):
help = (
"Create production superadmin login only (no demo sites/cycles/data). "
"Uses BOOTSTRAP_ADMIN_USER and BOOTSTRAP_ADMIN_PASSWORD from settings."
"Create production login accounts only (no demo sites/cycles/data). "
"Uses BOOTSTRAP_ADMIN_* and optional BOOTSTRAP_STAFF_* from settings."
)
def add_arguments(self, parser):
parser.add_argument(
"--force-password",
action="store_true",
help="Reset password even when the admin user already has one",
help="Reset password even when the user already has one",
)
@transaction.atomic
def handle(self, *args, **options):
user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip()
password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
if not password:
force_password = options["force_password"]
admin_user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip()
admin_password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
if not admin_password:
raise CommandError(
"BOOTSTRAP_ADMIN_PASSWORD is required. Set it in .env before running bootstrap_admin."
)
user, created = User.objects.get_or_create(
user_name=user_name,
defaults={
"status": User.STATUS_SUPERADMIN,
"is_staff": True,
"is_superuser": True,
},
admin = ensure_bootstrap_user(
user_name=admin_user_name,
password=admin_password,
status=User.STATUS_SUPERADMIN,
is_staff=True,
is_superuser=True,
force_password=force_password,
stdout=self.stdout,
)
should_set_password = created or not user.has_usable_password() or options["force_password"]
if should_set_password:
user.set_password(password)
user.status = User.STATUS_SUPERADMIN
user.is_staff = True
user.is_superuser = True
user.save()
action = "Created" if created else "Updated password for"
self.stdout.write(f"{action} superadmin {user_name}")
else:
user.status = User.STATUS_SUPERADMIN
user.is_superuser = True
user.save(update_fields=["status", "is_superuser", "updated_at"])
self.stdout.write(f"Superadmin {user_name} already exists (password unchanged)")
staff_user_name = (settings.BOOTSTRAP_STAFF_USER or "").strip()
staff_password = settings.BOOTSTRAP_STAFF_PASSWORD or ""
if staff_user_name:
if staff_user_name == admin_user_name:
raise CommandError(
"BOOTSTRAP_STAFF_USER must differ from BOOTSTRAP_ADMIN_USER."
)
if not staff_password:
raise CommandError(
"BOOTSTRAP_STAFF_PASSWORD is required when BOOTSTRAP_STAFF_USER is set."
)
ensure_bootstrap_user(
user_name=staff_user_name,
password=staff_password,
status=User.STATUS_ACTIVE,
is_staff=True,
is_superuser=False,
force_password=force_password,
stdout=self.stdout,
)
bootstrap_key = settings.BOOTSTRAP_API_KEY
if bootstrap_key:
api_key, _ = ApiKey.generate(user, "bootstrap")
api_key, _ = ApiKey.generate(admin, "bootstrap")
api_key.prefix = bootstrap_key[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap_key)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})")
accounts = admin_user_name
if staff_user_name:
accounts = f"{admin_user_name}, {staff_user_name}"
self.stdout.write(
self.style.SUCCESS(
f"Bootstrap OK: login={user_name} (no demo farm data — use Pengaturan to add sites/kandang/cycles)"
f"Bootstrap OK: login={accounts} "
"(no demo farm data — use Pengaturan to add sites/kandang/cycles)"
)
)
@@ -133,7 +133,7 @@ class Command(BaseCommand):
real_by_date = self.load_real_rows(Path(iot_json))
cycles = Cycle.objects.filter(
start_date=cycle_start, flocks__isnull=False
start_date=cycle_start, kandang__flocks__isnull=False
).distinct()
if not cycles:
raise CommandError(f"No cycles starting {cycle_start} with flocks")
@@ -141,7 +141,7 @@ class Command(BaseCommand):
seeded = 0
for cycle in cycles:
kandang_name = cycle.kandang.kandang_name
for flock in cycle.flocks.order_by("flock_id"):
for flock in cycle.kandang.flocks.order_by("flock_id"):
profile = self.profile_for(kandang_name, flock.flock_name)
rng = random.Random(options["jitter_seed"] + flock.pk)
rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng)
@@ -206,8 +206,11 @@ class Command(BaseCommand):
ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(flock__cycle=existing).delete()
Flock.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(
flock__kandang=kandang,
date__gte=existing.start_date,
date__lte=existing.end_date,
).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 1 cycle")
@@ -232,7 +235,7 @@ class Command(BaseCommand):
flocks_by_name: dict[str, Flock] = {}
for name in FLOCK_NAMES:
flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle)
flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang)
flocks_by_name[name] = flock
dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)]
@@ -426,7 +429,7 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
f"flocks={Flock.objects.filter(cycle=cycle).count()} "
f"flocks={Flock.objects.filter(kandang=kandang).count()} "
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None} "
@@ -96,8 +96,11 @@ class Command(BaseCommand):
ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(flock__cycle=existing).delete()
Flock.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(
flock__kandang=kandang,
date__gte=existing.start_date,
date__lte=existing.end_date,
).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 2 cycle")
@@ -122,7 +125,7 @@ class Command(BaseCommand):
flocks_by_name: dict[str, Flock] = {}
for name in ("Lantai 1", "Lantai 2"):
flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle)
flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang)
flocks_by_name[name] = flock
cc_by_date = {}
@@ -288,7 +291,7 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
f"flocks={Flock.objects.filter(cycle=cycle).count()} "
f"flocks={Flock.objects.filter(kandang=kandang).count()} "
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None}"
+30
View File
@@ -9,6 +9,8 @@ from django.core.management import call_command
@override_settings(
BOOTSTRAP_ADMIN_USER="admin",
BOOTSTRAP_ADMIN_PASSWORD="Pr04dm1n",
BOOTSTRAP_STAFF_USER="",
BOOTSTRAP_STAFF_PASSWORD="",
)
class BootstrapAdminTests(TestCase):
def test_creates_superadmin_without_demo_data(self):
@@ -23,3 +25,31 @@ class BootstrapAdminTests(TestCase):
with self.settings(BOOTSTRAP_ADMIN_PASSWORD=""):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
@override_settings(
BOOTSTRAP_STAFF_USER="staff",
BOOTSTRAP_STAFF_PASSWORD="St4ff!",
)
def test_creates_staff_when_configured(self):
call_command("bootstrap_admin")
staff = User.objects.get(user_name="staff")
self.assertEqual(staff.status, User.STATUS_ACTIVE)
self.assertTrue(staff.is_staff)
self.assertFalse(staff.is_superuser)
self.assertTrue(staff.check_password("St4ff!"))
@override_settings(
BOOTSTRAP_STAFF_USER="staff",
BOOTSTRAP_STAFF_PASSWORD="",
)
def test_requires_staff_password_when_user_set(self):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
@override_settings(
BOOTSTRAP_STAFF_USER="admin",
BOOTSTRAP_STAFF_PASSWORD="St4ff!",
)
def test_rejects_staff_same_as_admin(self):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
-19
View File
@@ -1,19 +0,0 @@
from django.contrib import admin
from apps.operations.models import (
AIInsight,
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
admin.site.register(KPI)
admin.site.register(ManualInput)
admin.site.register(ChickenCounting)
admin.site.register(ChickenWeight)
admin.site.register(FeedSacks)
admin.site.register(IotPanel)
admin.site.register(AIInsight)
-28
View File
@@ -1,11 +1,8 @@
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.snippets.models import register_snippet
from apps.farms.models import Cycle, Flock
@register_snippet
class IotPanel(models.Model):
panel_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -19,8 +16,6 @@ class IotPanel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("flock"), FieldPanel("date"), FieldPanel("average_temperature")]
class Meta:
db_table = "iot_panel"
ordering = ["-date"]
@@ -31,7 +26,6 @@ class IotPanel(models.Model):
]
@register_snippet
class AIInsight(models.Model):
date = models.DateField()
insight_text = models.TextField()
@@ -42,14 +36,11 @@ class AIInsight(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("insight_text"), FieldPanel("alert")]
class Meta:
db_table = "ai_insight"
ordering = ["-date"]
@register_snippet
class FeedSacks(models.Model):
fs_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -63,8 +54,6 @@ class FeedSacks(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("in_today"), FieldPanel("out_today")]
class Meta:
db_table = "feed_sacks"
ordering = ["-date"]
@@ -74,7 +63,6 @@ class FeedSacks(models.Model):
]
@register_snippet
class ChickenCounting(models.Model):
cc_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -84,8 +72,6 @@ class ChickenCounting(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("total_count")]
class Meta:
db_table = "chicken_counting"
ordering = ["-date"]
@@ -94,7 +80,6 @@ class ChickenCounting(models.Model):
]
@register_snippet
class ChickenWeight(models.Model):
cw_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -108,13 +93,6 @@ class ChickenWeight(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("cycle"),
FieldPanel("date"),
FieldPanel("average_weight"),
FieldPanel("uniformity"),
]
class Meta:
db_table = "chicken_weight"
ordering = ["-date"]
@@ -123,7 +101,6 @@ class ChickenWeight(models.Model):
]
@register_snippet
class ManualInput(models.Model):
manual_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -149,8 +126,6 @@ class ManualInput(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age_manual"), FieldPanel("fcr_manual")]
class Meta:
db_table = "manual_input"
ordering = ["-date"]
@@ -159,7 +134,6 @@ class ManualInput(models.Model):
]
@register_snippet
class KPI(models.Model):
kpi_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -187,8 +161,6 @@ class KPI(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age"), FieldPanel("fcr"), FieldPanel("eef")]
class Meta:
db_table = "kpi"
ordering = ["-date"]
+2 -3
View File
@@ -19,7 +19,7 @@ class CycleContextMixin(serializers.Serializer):
flock_id = serializers.SerializerMethodField()
def get_flock_id(self, obj):
flock = obj.cycle.flocks.order_by("flock_id").first()
flock = obj.cycle.kandang.flocks.order_by("flock_id").first()
return flock.pk if flock is not None else None
@@ -124,8 +124,7 @@ class IotPanelSerializer(PkAsIdMixin, serializers.ModelSerializer):
read_only_fields = ["id", "created_at", "updated_at"]
def get_kandang(self, obj):
cycle = getattr(obj.flock, "cycle", None)
return cycle.kandang_id if cycle is not None else None
return obj.flock.kandang_id
class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
+12 -3
View File
@@ -11,7 +11,7 @@ import httpx
from django.conf import settings
from django.utils import timezone
from apps.farms.models import Flock
from apps.farms.models import Cycle, Flock
from apps.operations.models import IotPanel
WIB = ZoneInfo("Asia/Jakarta")
@@ -199,7 +199,7 @@ def sync_iot_panels(
django_flocks: dict[str, Flock] = {}
for external_id, django_id in flock_map.items():
try:
django_flocks[str(external_id)] = Flock.objects.select_related("cycle").get(pk=int(django_id))
django_flocks[str(external_id)] = Flock.objects.select_related("kandang").get(pk=int(django_id))
except Flock.DoesNotExist:
continue
@@ -236,7 +236,16 @@ def sync_iot_panels(
continue
snapshot_date = fetched_at.astimezone(WIB).date()
age = cycle_age_for_date(flock.cycle.start_date, snapshot_date)
cycle = (
Cycle.objects.filter(
kandang_id=flock.kandang_id,
start_date__lte=snapshot_date,
end_date__gte=snapshot_date,
)
.order_by("-start_date")
.first()
)
age = cycle_age_for_date(cycle.start_date, snapshot_date) if cycle else 1
fields = panel_fields_from_payload(payload_data, age)
IotPanel.objects.update_or_create(
+3 -3
View File
@@ -26,7 +26,7 @@ class OperationsAPITests(TestCase):
doc_in_count=10000,
status=Cycle.STATUS_ACTIVE,
)
self.flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle)
self.flock = Flock.objects.create(flock_name="Flock A", kandang=self.kandang)
def test_karungs_alias_lists_feed_sacks_with_date(self):
FeedSacks.objects.create(
@@ -65,7 +65,7 @@ class OperationsAPITests(TestCase):
self.assertEqual(results[0]["date"], date.today().isoformat())
def test_panel_iot_filters_by_flock(self):
flock_b = Flock.objects.create(flock_name="Flock B", cycle=self.cycle)
flock_b = Flock.objects.create(flock_name="Flock B", kandang=self.kandang)
row = IotPanel.objects.create(
flock=self.flock,
date=date.today(),
@@ -379,7 +379,7 @@ class DashboardVisibilityTests(TestCase):
self.assertEqual(response.data["date"], "2026-08-28")
def test_panel_iot_not_hidden_before_publish_time(self):
flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle)
flock = Flock.objects.create(flock_name="Flock A", kandang=self.kandang)
IotPanel.objects.create(
flock=flock,
date=date(2026, 8, 28),
+13 -4
View File
@@ -53,7 +53,7 @@ class CycleScopedViewSet(VisibilityFilteredMixin, viewsets.ModelViewSet):
if params.get("cycle_id"):
qs = qs.filter(cycle_id=params["cycle_id"])
if params.get("flock_id"):
qs = qs.filter(cycle__flocks__flock_id=params["flock_id"]).distinct()
qs = qs.filter(cycle__kandang__flocks=params["flock_id"]).distinct()
if params.get("date"):
qs = qs.filter(date=params["date"])
return self.filter_visible_through(qs)
@@ -143,14 +143,23 @@ class FeedSacksViewSet(CycleScopedViewSet):
class IotPanelViewSet(viewsets.ModelViewSet):
"""IoT panel is real-time — not subject to the 17:00 dashboard publish cutoff."""
queryset = IotPanel.objects.select_related("flock", "flock__cycle").all()
queryset = IotPanel.objects.select_related("flock", "flock__kandang").all()
serializer_class = IotPanelSerializer
def get_queryset(self):
qs = super().get_queryset()
params = self.request.query_params
if params.get("cycle_id"):
qs = qs.filter(flock__cycle_id=params["cycle_id"])
cycle_id = params.get("cycle_id")
if cycle_id:
try:
cycle = Cycle.objects.get(pk=cycle_id)
except Cycle.DoesNotExist:
return qs.none()
qs = qs.filter(
flock__kandang_id=cycle.kandang_id,
date__gte=cycle.start_date,
date__lte=cycle.end_date,
)
if params.get("flock_id"):
qs = qs.filter(flock_id=params["flock_id"])
if params.get("date"):
+7 -24
View File
@@ -1,4 +1,4 @@
"""Django settings for New Dashboard (ERD-aligned Wagtail + DRF)."""
"""Django settings for New Dashboard (ERD-aligned DRF API)."""
from __future__ import annotations
@@ -42,24 +42,6 @@ ALLOWED_HOSTS = env_list("ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS", "http://localhost:8000")
INSTALLED_APPS = [
"apps.accounts",
"apps.farms",
"apps.operations",
"apps.jobs",
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
"wagtail.embeds",
"wagtail.sites",
"wagtail.users",
"wagtail.snippets",
"wagtail.documents",
"wagtail.images",
"wagtail.search",
"wagtail.admin",
"wagtail",
"modelcluster",
"taggit",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
@@ -68,6 +50,10 @@ INSTALLED_APPS = [
"rest_framework",
"corsheaders",
"drf_spectacular",
"apps.accounts",
"apps.farms",
"apps.operations",
"apps.jobs",
]
MIDDLEWARE = [
@@ -79,7 +65,6 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
]
ROOT_URLCONF = "config.urls"
@@ -142,10 +127,6 @@ MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
WAGTAIL_SITE_NAME = env("WAGTAIL_SITE_NAME", "New Dashboard")
WAGTAILADMIN_BASE_URL = env("WAGTAILADMIN_BASE_URL", "http://localhost:8000")
WAGTAIL_APPEND_SLASH = False
CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS", "http://localhost:3000")
CORS_ALLOW_CREDENTIALS = env_bool("CORS_ALLOW_CREDENTIALS", True)
@@ -177,6 +158,8 @@ BOOTSTRAP_API_KEY = env("BOOTSTRAP_API_KEY", "") or ""
BOOTSTRAP_ADMIN_USER = env("BOOTSTRAP_ADMIN_USER", "admin") or "admin"
BOOTSTRAP_ADMIN_PASSWORD = env("BOOTSTRAP_ADMIN_PASSWORD", "") or ""
BOOTSTRAP_STAFF_USER = env("BOOTSTRAP_STAFF_USER", "") or ""
BOOTSTRAP_STAFF_PASSWORD = env("BOOTSTRAP_STAFF_PASSWORD", "") or ""
KARUNG_WEB_ADMIN_BASE_URL = (
env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or ""
-8
View File
@@ -1,16 +1,9 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
urlpatterns = [
path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/docs/",
@@ -20,7 +13,6 @@ urlpatterns = [
path("api/v1/", include("apps.accounts.urls")),
path("api/v1/", include("apps.farms.urls")),
path("api/v1/", include("apps.operations.urls")),
path("", include(wagtail_urls)),
]
if settings.DEBUG:
-2
View File
@@ -1,5 +1,4 @@
Django>=5.1,<5.3
wagtail>=6.3,<7.0
djangorestframework>=3.15,<4.0
drf-spectacular>=0.27,<1.0
django-cors-headers>=4.4,<5.0
@@ -7,5 +6,4 @@ python-dotenv>=1.0,<2.0
gunicorn>=23.0,<24.0
psycopg[binary]>=3.2,<4.0
httpx>=0.27,<1.0
Pillow>=10.0,<12.0
openpyxl>=3.1,<4.0
+3 -3
View File
@@ -76,7 +76,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
}, [selectedChartKey]);
useEffect(() => {
if (!selectedCycle) {
if (!selectedKandang) {
setFlocks([]);
setSelectedFlockId(null);
setDates([]);
@@ -89,7 +89,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
(async () => {
setError(null);
try {
const data = await api.flocks.list({ cycle_id: selectedCycle.id });
const data = await api.flocks.list({ kandang_id: selectedKandang.id });
if (cancelled) return;
setFlocks(data);
setSelectedFlockId((previous) =>
@@ -102,7 +102,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
return () => {
cancelled = true;
};
}, [selectedCycle, refreshToken]);
}, [selectedKandang, refreshToken]);
useEffect(() => {
if (!selectedCycle || selectedFlockId == null) {
+78 -15
View File
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts';
import type { CycleStatus } from '../types/api.ts';
import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, PageHeader } from './ui/Feedback.tsx';
@@ -14,6 +13,7 @@ const SettingsPage: React.FC = () => {
const {
sites,
kandangs,
flocks,
cycles,
selectedSiteId,
selectedKandangId,
@@ -27,11 +27,11 @@ const SettingsPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [siteName, setSiteName] = useState('');
const [kandangName, setKandangName] = useState('');
const [flockName, setFlockName] = useState('');
const [startDate, setStartDate] = useState(todayIso());
const [endDate, setEndDate] = useState(() => addDaysIso(todayIso(), 34));
const [docInCount, setDocInCount] = useState(20000);
const [chickInWeight, setChickInWeight] = useState(45);
const [cycleStatus, setCycleStatus] = useState<CycleStatus>('active');
const [chickInWeight, setChickInWeight] = useState(35);
const flash = (msg: string) => {
setNotice(msg);
@@ -55,7 +55,10 @@ const SettingsPage: React.FC = () => {
return (
<div>
<PageHeader title="Pengaturan" subtitle="Site, kandang, dan siklus produksi" />
<PageHeader
title="Pengaturan"
subtitle="Site, kandang, flock (IoT), dan siklus produksi"
/>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
@@ -66,7 +69,7 @@ const SettingsPage: React.FC = () => {
<Banner kind="success">{notice}</Banner>
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-6">
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-3">Sites</h2>
{isSuperAdmin ? (
@@ -188,6 +191,69 @@ const SettingsPage: React.FC = () => {
))}
</ul>
</section>
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-3">Flock</h2>
<p className="mb-3 text-xs text-gray-500">
Flock tetap untuk kandang ini (dipakai Panel IoT). Tidak terikat ke siklus.
</p>
{isSuperAdmin ? (
<form
className="space-y-2 mb-4"
onSubmit={(e) => {
e.preventDefault();
if (!selectedKandangId) return;
void wrap(async () => {
const created = await api.flocks.create({
flock_name: flockName,
kandang: selectedKandangId,
});
setFlockName('');
flash(`Flock ${created.flock_name} dibuat.`);
});
}}
>
<input
className={inputClass}
placeholder="Nama flock"
value={flockName}
onChange={(e) => setFlockName(e.target.value)}
required
/>
<button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving || !selectedKandangId}
>
Tambah flock
</button>
</form>
) : (
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat flock.</p>
)}
{!selectedKandangId ? (
<p className="text-xs text-gray-500">Pilih kandang terlebih dahulu.</p>
) : (
<ul className="divide-y">
{flocks.map((flock) => (
<li key={flock.id} className="py-2 flex items-center justify-between gap-2">
<span className="text-sm text-gray-700">{flock.flock_name}</span>
{isSuperAdmin && (
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
await api.flocks.remove(flock.id);
flash('Flock dihapus.');
})
}
>
Hapus
</button>
)}
</li>
))}
</ul>
)}
</section>
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-3">Siklus</h2>
<form
@@ -204,7 +270,6 @@ const SettingsPage: React.FC = () => {
end_date: endDate,
chick_in_weight: chickInWeight,
doc_in_count: docInCount,
status: cycleStatus,
});
flash('Siklus dibuat.');
});
@@ -247,18 +312,16 @@ const SettingsPage: React.FC = () => {
<input
type="number"
className={`${inputClass} mt-1`}
min={35}
max={51}
value={chickInWeight}
onChange={(e) => setChickInWeight(Number(e.target.value))}
onChange={(e) => {
const value = Number(e.target.value);
if (Number.isNaN(value)) return;
setChickInWeight(Math.min(51, Math.max(35, value)));
}}
/>
</label>
<select
className={inputClass}
value={cycleStatus}
onChange={(e) => setCycleStatus(e.target.value as CycleStatus)}
>
<option value="active">active</option>
<option value="closed">closed</option>
</select>
<button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving}
+70 -24
View File
@@ -2,25 +2,36 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SettingsPage from '../SettingsPage.tsx';
const { createKandangMock, createCycleMock, reloadMock, farmState } = vi.hoisted(() => ({
createKandangMock: vi.fn(),
createCycleMock: vi.fn(),
reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: {
sites: [{ id: 7, site_name: 'Site A', user: 99, created_at: '', updated_at: '' }],
kandangs: [] as Array<{
id: number;
kandang_name: string;
site: number;
created_at: string;
updated_at: string;
}>,
cycles: [] as unknown[],
selectedSiteId: 7,
selectedKandangId: null as number | null,
selectedCycle: null as unknown,
},
}));
const { createKandangMock, createFlockMock, createCycleMock, reloadMock, farmState } = vi.hoisted(
() => ({
createKandangMock: vi.fn(),
createFlockMock: vi.fn(),
createCycleMock: vi.fn(),
reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: {
sites: [{ id: 7, site_name: 'Site A', user: 99, created_at: '', updated_at: '' }],
kandangs: [] as Array<{
id: number;
kandang_name: string;
site: number;
created_at: string;
updated_at: string;
}>,
flocks: [] as Array<{
id: number;
flock_name: string;
kandang: number;
created_at: string;
updated_at: string;
}>,
cycles: [] as unknown[],
selectedSiteId: 7,
selectedKandangId: null as number | null,
selectedCycleId: null as number | null,
selectedCycle: null as unknown,
},
})
);
vi.mock('../../services/apiClient.ts', () => ({
api: {
@@ -32,6 +43,10 @@ vi.mock('../../services/apiClient.ts', () => ({
create: createKandangMock,
remove: vi.fn(),
},
flocks: {
create: createFlockMock,
remove: vi.fn(),
},
cycles: {
create: createCycleMock,
remove: vi.fn(),
@@ -56,9 +71,11 @@ vi.mock('../../context/FarmContext.tsx', () => ({
useFarm: () => ({
sites: farmState.sites,
kandangs: farmState.kandangs,
flocks: farmState.flocks,
cycles: farmState.cycles,
selectedSiteId: farmState.selectedSiteId,
selectedKandangId: farmState.selectedKandangId,
selectedCycleId: farmState.selectedCycleId,
selectedCycle: farmState.selectedCycle,
reload: reloadMock,
setSelectedSiteId: vi.fn(),
@@ -70,12 +87,15 @@ vi.mock('../../context/FarmContext.tsx', () => ({
describe('SettingsPage settings forms', () => {
beforeEach(() => {
createKandangMock.mockReset();
createFlockMock.mockReset();
createCycleMock.mockReset();
reloadMock.mockClear();
farmState.kandangs = [];
farmState.flocks = [];
farmState.cycles = [];
farmState.selectedSiteId = 7;
farmState.selectedKandangId = null;
farmState.selectedCycleId = null;
farmState.selectedCycle = null;
createKandangMock.mockResolvedValue({
id: 11,
@@ -84,15 +104,21 @@ describe('SettingsPage settings forms', () => {
created_at: '',
updated_at: '',
});
createFlockMock.mockResolvedValue({
id: 3,
flock_name: 'Lantai 1',
kandang: 11,
created_at: '',
updated_at: '',
});
createCycleMock.mockResolvedValue({
id: 21,
kandang: 11,
flock: 3,
total_days: 35,
current_day: 1,
start_date: '2026-08-18',
end_date: '2026-09-21',
chick_in_weight: 45,
chick_in_weight: 35,
doc_in_count: 20000,
status: 'active',
created_at: '',
@@ -116,7 +142,7 @@ describe('SettingsPage settings forms', () => {
});
});
it('submits cycle fields expected by the backend contract', async () => {
it('submits flock fields for the selected kandang', async () => {
farmState.kandangs = [
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
];
@@ -124,6 +150,27 @@ describe('SettingsPage settings forms', () => {
render(<SettingsPage />);
fireEvent.change(screen.getByPlaceholderText('Nama flock'), {
target: { value: 'Lantai 1' },
});
fireEvent.click(screen.getByRole('button', { name: 'Tambah flock' }));
await waitFor(() => expect(createFlockMock).toHaveBeenCalledTimes(1));
expect(createFlockMock).toHaveBeenCalledWith({
flock_name: 'Lantai 1',
kandang: 11,
});
});
it('submits cycle fields without flock assignment', async () => {
farmState.kandangs = [
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
];
farmState.selectedKandangId = 11;
render(<SettingsPage />);
expect(screen.queryByLabelText(/^Siklus$/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Total hari/i)).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Mulai/i), { target: { value: '2026-08-01' } });
@@ -137,9 +184,8 @@ describe('SettingsPage settings forms', () => {
current_day: 1,
start_date: '2026-08-01',
end_date: '2026-09-04',
chick_in_weight: 45,
chick_in_weight: 35,
doc_in_count: 20000,
status: 'active',
});
});
});
+44 -431
View File
@@ -30,13 +30,6 @@ type Row = {
isFuture: boolean;
};
type EditValues = {
mati: number;
panen: number;
beratPanen: string;
chickenCount?: number;
};
const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
const [kpis, setKpis] = useState<KPI[]>([]);
@@ -44,19 +37,11 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingDay, setEditingDay] = useState<number | null>(null);
const [editValues, setEditValues] = useState<EditValues>({
mati: 0,
panen: 0,
beratPanen: '',
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [searchDay, setSearchDay] = useState<string>('');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const isReadOnly = !selectedCycle || selectedCycle.status !== 'active';
const isClosedCycle = !selectedCycle || selectedCycle.status !== 'active';
const loadData = async (cycleId: number) => {
setLoading(true);
@@ -85,7 +70,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
setCountings([]);
setManualRows([]);
setInsights([]);
setEditingDay(null);
return;
}
void loadData(selectedCycle.id);
@@ -222,104 +206,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
);
};
const handleEdit = (record: Row) => {
setEditingDay(record.day);
setEditValues({
mati: record.mati,
panen: record.panen,
beratPanen: record.beratPanen ? record.beratPanen.toString() : '',
chickenCount: record.chickenCount,
});
setValidationErrors({});
};
const validateEditValues = (record: Row): boolean => {
const errors: Record<string, string> = {};
const beratPanenValue = editValues.beratPanen === '' ? 0 : parseFloat(editValues.beratPanen);
if (editValues.mati < 0) {
errors.mati = 'Mati tidak boleh negatif';
} else if (editValues.mati > record.stockAwal) {
errors.mati = `Mati tidak boleh lebih dari stock awal (${record.stockAwal})`;
}
if (editValues.panen < 0) {
errors.panen = 'Panen tidak boleh negatif';
} else if (editValues.panen > record.stockAwal - editValues.mati) {
errors.panen = `Panen tidak boleh lebih dari sisa (${record.stockAwal - editValues.mati})`;
}
if (editValues.chickenCount !== undefined && editValues.chickenCount < 0) {
errors.chickenCount = 'Chicken counting tidak boleh negatif';
}
if (Number.isNaN(beratPanenValue) || beratPanenValue < 0) {
errors.beratPanen = 'Berat panen tidak boleh negatif';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = async () => {
if (editingDay === null || !selectedCycle) return;
const record = rows.find((r) => r.day === editingDay);
if (!record) return;
if (!validateEditValues(record)) return;
setSaving(true);
try {
const beratPanenValue = editValues.beratPanen === '' ? 0 : parseFloat(editValues.beratPanen);
const manualChanged =
editValues.mati !== record.mati ||
editValues.panen !== record.panen ||
beratPanenValue !== record.beratPanen;
if (manualChanged) {
await api.manualInputs.create({
cycle: selectedCycle.id,
date: record.date,
age_manual: record.day,
mortality_manual: editValues.mati,
harvest_manual: editValues.panen,
// Input pengguna dalam kg, disimpan ke database dalam gram.
harvest_weight_manual: beratPanenValue * 1000,
});
}
if (
editValues.chickenCount !== undefined &&
editValues.chickenCount !== record.chickenCount
) {
const existing = countings.find((c) => c.date === record.date);
if (existing) {
await api.countings.update(existing.id, { total_count: editValues.chickenCount });
} else {
await api.countings.create({
cycle: selectedCycle.id,
date: record.date,
total_count: editValues.chickenCount,
});
}
}
setEditingDay(null);
setValidationErrors({});
await loadData(selectedCycle.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const handleCancel = () => {
setEditingDay(null);
setValidationErrors({});
};
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
@@ -328,7 +214,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<PageHeader
title="Hitung Ayam"
icon={<i className="fa-solid fa-calculator" />}
subtitle={`Kelola dan edit data deplesi harian untuk siklus produksi aktif · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
subtitle={`Lihat data deplesi harian untuk siklus produksi · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
/>
{/* AI Insight */}
@@ -488,8 +374,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
</div>
</div>
{/* Read-only banner for closed cycles */}
{isReadOnly && (
{isClosedCycle && (
<div className="mb-6 bg-amber-50 border-l-4 border-amber-400 rounded-lg p-4">
<div className="flex items-center">
<i className="fa-solid fa-eye text-amber-500 text-xl mr-4"></i>
@@ -497,9 +382,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<p className="text-sm text-amber-800 font-semibold">
Mode Lihat Saja (Siklus Selesai)
</p>
<p className="text-xs text-amber-700 mt-1">
Untuk mengedit data, pilih siklus yang masih aktif pada dropdown di atas.
</p>
</div>
</div>
</div>
@@ -515,7 +397,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
</div>
) : (
filteredRecords.map((record) => {
const isEditing = !isReadOnly && editingDay === record.day;
const cardTone = record.isToday
? 'ring-2 ring-blue-200 bg-blue-50/40'
: record.panen > 0
@@ -527,11 +408,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
key={record.day}
className={`rounded-2xl border border-gray-200 shadow-sm overflow-hidden ${cardTone}`}
>
<div
className={`flex items-start justify-between gap-3 px-4 py-4 ${
isEditing ? 'bg-blue-50' : 'bg-gray-50/80'
}`}
>
<div className="flex items-start justify-between gap-3 px-4 py-4 bg-gray-50/80">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white">
@@ -557,38 +434,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
{record.dateLabel}
</div>
</div>
{!isReadOnly && (
<div className="flex-shrink-0">
{isEditing ? (
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={saving}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-green-500 text-white shadow-sm transition-colors hover:bg-green-600 disabled:opacity-60"
aria-label="Simpan"
>
<i className="fa-solid fa-check"></i>
</button>
<button
onClick={handleCancel}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-gray-500 text-white shadow-sm transition-colors hover:bg-gray-600"
aria-label="Batal"
>
<i className="fa-solid fa-times"></i>
</button>
</div>
) : (
<button
onClick={() => handleEdit(record)}
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700"
>
<i className="fa-solid fa-edit"></i>
Edit
</button>
)}
</div>
)}
</div>
<div className="p-4">
@@ -606,92 +451,27 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<div className="text-[11px] font-semibold uppercase tracking-wide text-red-500">
Mati
</div>
{isEditing ? (
<div>
<input
type="number"
value={editValues.mati}
onChange={(e) =>
setEditValues({
...editValues,
mati: parseInt(e.target.value) || 0,
})
}
className="mt-1 w-full rounded-lg border border-red-200 bg-white px-2 py-2 text-right text-[11px] focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-200 md:px-3 md:text-sm"
/>
{validationErrors.mati && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.mati}
</div>
)}
</div>
) : (
<div className="text-[11px] font-semibold text-red-600 md:text-sm">
{formatNumber(record.mati, 0)}
</div>
)}
<div className="text-[11px] font-semibold text-red-600 md:text-sm">
{formatNumber(record.mati, 0)}
</div>
</div>
<div className="rounded-xl bg-purple-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
Panen
</div>
{isEditing ? (
<div>
<input
type="number"
value={editValues.panen}
onChange={(e) =>
setEditValues({
...editValues,
panen: parseInt(e.target.value) || 0,
})
}
className="mt-1 w-full rounded-lg border border-purple-200 bg-white px-2 py-2 text-right text-[11px] focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200 md:px-3 md:text-sm"
/>
{validationErrors.panen && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.panen}
</div>
)}
</div>
) : (
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
{formatNumber(record.panen, 0)}
</div>
)}
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
{formatNumber(record.panen, 0)}
</div>
</div>
<div className="rounded-xl bg-purple-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
Berat panen (kg)
</div>
{isEditing ? (
<div>
<input
type="number"
step="0.01"
min="0"
value={editValues.beratPanen}
onChange={(e) =>
setEditValues({
...editValues,
beratPanen: e.target.value,
})
}
className="mt-1 w-full rounded-lg border border-purple-200 bg-white px-2 py-2 text-right text-[11px] focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200 md:px-3 md:text-sm"
/>
{validationErrors.beratPanen && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.beratPanen}
</div>
)}
</div>
) : (
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg
</div>
)}
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg
</div>
</div>
<div className="rounded-xl bg-slate-50 p-3">
@@ -699,12 +479,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
Stock akhir
</div>
<div className="text-[11px] font-semibold text-slate-900 md:text-sm">
{isEditing
? Math.max(
0,
record.stockAwal - editValues.mati - editValues.panen
).toLocaleString('id-ID')
: formatNumber(record.stockAkhir, 0)}
{formatNumber(record.stockAkhir, 0)}
</div>
</div>
@@ -712,35 +487,11 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<div className="text-[11px] font-semibold uppercase tracking-wide text-blue-500">
Chicken Counting
</div>
{isEditing ? (
<div>
<input
type="number"
value={editValues.chickenCount || ''}
onChange={(e) =>
setEditValues({
...editValues,
chickenCount: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
placeholder="0"
className="mt-1 w-full rounded-lg border border-blue-200 bg-white px-2 py-2 text-right text-[11px] focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:px-3 md:text-sm"
/>
{validationErrors.chickenCount && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.chickenCount}
</div>
)}
</div>
) : (
<div className="text-[11px] font-semibold text-blue-600 md:text-sm">
{record.chickenCount !== undefined
? formatNumber(record.chickenCount, 0)
: '-'}
</div>
)}
<div className="text-[11px] font-semibold text-blue-600 md:text-sm">
{record.chickenCount !== undefined
? formatNumber(record.chickenCount, 0)
: '-'}
</div>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
@@ -801,18 +552,10 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
</th>
<th
rowSpan={2}
className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700"
>
Persentase
</th>
{!isReadOnly && (
<th
rowSpan={2}
className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700"
>
Aksi
</th>
)}
</tr>
{/* Second header row with individual column names */}
<tr className="border-b border-gray-200">
@@ -834,7 +577,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
{filteredRecords.length === 0 ? (
<tr>
<td
colSpan={isReadOnly ? 9 : 10}
colSpan={9}
className="px-6 py-12 text-center text-gray-500"
>
<i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i>
@@ -843,7 +586,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
</tr>
) : (
filteredRecords.map((record, index) => {
const isEditing = !isReadOnly && editingDay === record.day;
const rowClass = `hover:bg-gray-100 transition-colors ${
index % 2 === 0 ? 'bg-white' : 'bg-gray-50'
} ${record.panen > 0 ? 'border-l-4 border-purple-400' : ''}`;
@@ -862,175 +604,50 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
{record.dateLabel}
</td>
{/* Stock awal (calculated, read-only) */}
{/* Stock awal */}
<td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center text-[11px] text-gray-500 md:text-sm">
{formatNumber(record.stockAwal, 0)}
</td>
{/* Mati (editable) */}
{/* Mati */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? (
<div>
<input
type="number"
value={editValues.mati}
onChange={(e) =>
setEditValues({
...editValues,
mati: parseInt(e.target.value) || 0,
})
}
className="w-20 px-2 py-1 border border-blue-500 rounded text-right text-[11px] focus:ring-2 focus:ring-blue-500 md:w-24 md:text-sm"
/>
{validationErrors.mati && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.mati}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-red-600 md:text-sm">
{formatNumber(record.mati, 0)}
</span>
)}
<span className="text-[11px] font-semibold text-red-600 md:text-sm">
{formatNumber(record.mati, 0)}
</span>
</td>
{/* JUMLAH PANEN (editable) */}
{/* JUMLAH PANEN */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? (
<div>
<input
type="number"
value={editValues.panen}
onChange={(e) =>
setEditValues({
...editValues,
panen: parseInt(e.target.value) || 0,
})
}
className="w-20 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-24 md:text-sm"
/>
{validationErrors.panen && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.panen}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
{formatNumber(record.panen, 0)}
</span>
)}
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
{formatNumber(record.panen, 0)}
</span>
</td>
{/* BERAT PANEN (editable) */}
{/* BERAT PANEN */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? (
<div>
<input
type="number"
step="0.01"
min="0"
value={editValues.beratPanen}
onChange={(e) =>
setEditValues({
...editValues,
beratPanen: e.target.value,
})
}
className="w-24 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-28 md:text-sm"
/>
{validationErrors.beratPanen && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.beratPanen}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'}
</span>
)}
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'}
</span>
</td>
{/* Stock akhir (calculated, read-only) */}
{/* Stock akhir */}
<td className="px-2 py-2 whitespace-nowrap text-center text-[11px] font-bold text-gray-800 border-r border-gray-200 md:text-sm">
{isEditing
? Math.max(
0,
record.stockAwal - editValues.mati - editValues.panen
).toLocaleString('id-ID')
: formatNumber(record.stockAkhir, 0)}
{formatNumber(record.stockAkhir, 0)}
</td>
{/* Chicken Counting (editable) */}
{/* Chicken Counting */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? (
<div>
<input
type="number"
value={editValues.chickenCount || ''}
onChange={(e) =>
setEditValues({
...editValues,
chickenCount: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
placeholder="0"
className="w-24 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-28 md:text-sm"
/>
{validationErrors.chickenCount && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.chickenCount}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-blue-600 md:text-sm">
{record.chickenCount !== undefined
? formatNumber(record.chickenCount, 0)
: '-'}
</span>
)}
<span className="text-[11px] font-semibold text-blue-600 md:text-sm">
{record.chickenCount !== undefined
? formatNumber(record.chickenCount, 0)
: '-'}
</span>
</td>
{/* Persentase (calculated, badge) */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{/* Persentase */}
<td className="px-2 py-2 whitespace-nowrap text-center">
{getPersentaseBadge(record.persentase)}
</td>
{/* Aksi */}
{!isReadOnly && (
<td className="px-2 py-2 whitespace-nowrap text-center">
{isEditing ? (
<div className="flex justify-center gap-2">
<button
onClick={handleSave}
disabled={saving}
className="px-2 py-1 bg-green-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-green-600 disabled:opacity-60 md:px-3 md:text-sm"
>
<i className="fa-solid fa-check"></i>
</button>
<button
onClick={handleCancel}
className="px-2 py-1 bg-gray-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-gray-600 md:px-3 md:text-sm"
>
<i className="fa-solid fa-times"></i>
</button>
</div>
) : (
<button
onClick={() => handleEdit(record)}
className="px-2 py-1 bg-blue-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-blue-600 md:px-3 md:text-sm"
>
<i className="fa-solid fa-edit mr-1"></i>
Edit
</button>
)}
</td>
)}
</tr>
);
})
@@ -1079,10 +696,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>Chicken Counting (IOT)</strong> = hasil hitung otomatis dari IOT
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>Data yang diedit akan
tersimpan sebagai input manual dan ditandai dengan highlight kuning
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>Baris dengan border ungu
menandakan ada panen pada hari tersebut
@@ -68,5 +68,6 @@ describe('DataManagementPage', () => {
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /Input manual/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Simpan hitungan/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Edit/i })).not.toBeInTheDocument();
});
});
+26 -1
View File
@@ -1,11 +1,12 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts';
import type { Cycle, Kandang, Site } from '../types/api.ts';
import type { Cycle, Flock, Kandang, Site } from '../types/api.ts';
import { useAuth } from './AuthContext.tsx';
type FarmContextValue = {
sites: Site[];
kandangs: Kandang[];
flocks: Flock[];
cycles: Cycle[];
selectedSiteId: number | null;
selectedKandangId: number | null;
@@ -27,6 +28,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
const { user } = useAuth();
const [sites, setSites] = useState<Site[]>([]);
const [kandangs, setKandangs] = useState<Kandang[]>([]);
const [flocks, setFlocks] = useState<Flock[]>([]);
const [cycles, setCycles] = useState<Cycle[]>([]);
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null);
const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null);
@@ -43,6 +45,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (!user) {
setSites([]);
setKandangs([]);
setFlocks([]);
setCycles([]);
return;
}
@@ -94,6 +97,26 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
};
}, [user, selectedSiteId, reloadToken]);
useEffect(() => {
if (!user || selectedKandangId == null) {
setFlocks([]);
return;
}
let cancelled = false;
(async () => {
try {
const next = await api.flocks.list({ kandang_id: selectedKandangId });
if (cancelled) return;
setFlocks(next);
} catch (err) {
if (!cancelled) setError(errorMessage(err));
}
})();
return () => {
cancelled = true;
};
}, [user, selectedKandangId, reloadToken]);
useEffect(() => {
if (!user || selectedKandangId == null) {
setCycles([]);
@@ -138,6 +161,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
() => ({
sites,
kandangs,
flocks,
cycles,
selectedSiteId,
selectedKandangId,
@@ -155,6 +179,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
[
sites,
kandangs,
flocks,
cycles,
selectedSiteId,
selectedKandangId,
+30 -25
View File
@@ -1,22 +1,24 @@
name: dashboard-cpsp
services:
database:
image: postgres:16-alpine
container_name: newdashboard-database
container_name: dashboard-cpsp-database
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-newdashboard}
POSTGRES_PASSWORD: ${DB_PASSWORD:-change_this_password}
POSTGRES_DB: ${DB_NAME:-newdashboard}
POSTGRES_USER: ${DB_USER:-dashboard}
POSTGRES_PASSWORD: ${DB_PASSWORD:-Ket0pr4k}
POSTGRES_DB: ${DB_NAME:-dashboard}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- db-data:/var/lib/postgresql/data
networks:
- newdashboard-network
- dashboard-cpsp-network
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${DB_USER:-newdashboard} -d ${DB_NAME:-newdashboard}",
"pg_isready -U ${DB_USER:-dashboard} -d ${DB_NAME:-dashboard}",
]
interval: 10s
timeout: 5s
@@ -28,28 +30,27 @@ services:
- "127.0.0.1:15432:5432"
api:
image: dashboard-cpsp-api
build:
context: .
dockerfile: docker/Dockerfile.api
container_name: newdashboard-api
container_name: dashboard-cpsp-api
restart: unless-stopped
ports:
- "127.0.0.1:18000:8000"
environment:
DEBUG: ${DEBUG:-false}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
SECRET_KEY: ${SECRET_KEY:-test-secret-key}
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend}
CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:-http://localhost:5002,http://127.0.0.1:5002}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5002,http://127.0.0.1:5002}
CORS_ALLOW_CREDENTIALS: "true"
DB_ENGINE: django.db.backends.postgresql
DB_NAME: ${DB_NAME:-newdashboard}
DB_USER: ${DB_USER:-newdashboard}
DB_PASSWORD: ${DB_PASSWORD:-change_this_password}
DB_NAME: ${DB_NAME:-dashboard}
DB_USER: ${DB_USER:-dashboard}
DB_PASSWORD: ${DB_PASSWORD:-Ket0pr4k}
DB_HOST: database
DB_PORT: "5432"
WAGTAIL_SITE_NAME: ${WAGTAIL_SITE_NAME:-New Dashboard}
WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://localhost:5002}
KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000}
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS: ${KARUNG_WEB_ADMIN_TIMEOUT_SECONDS:-30}
KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}
@@ -60,9 +61,11 @@ services:
IOT_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}}
IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20}
IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10}
BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-}
BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-test-api-key}
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-Pr04dm1n}
BOOTSTRAP_STAFF_USER: ${BOOTSTRAP_STAFF_USER:-skw}
BOOTSTRAP_STAFF_PASSWORD: ${BOOTSTRAP_STAFF_PASSWORD:-skw123}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
@@ -71,7 +74,7 @@ services:
database:
condition: service_healthy
networks:
- newdashboard-network
- dashboard-cpsp-network
healthcheck:
test:
[
@@ -86,20 +89,21 @@ services:
start_period: 60s
cron:
image: dashboard-cpsp-cron
build:
context: .
dockerfile: docker/Dockerfile.api
container_name: newdashboard-cron
container_name: dashboard-cpsp-cron
restart: unless-stopped
entrypoint: ["/app/entrypoint-cron.sh"]
environment:
DEBUG: ${DEBUG:-false}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
SECRET_KEY: ${SECRET_KEY:-test-secret-key}
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend}
DB_ENGINE: django.db.backends.postgresql
DB_NAME: ${DB_NAME:-newdashboard}
DB_USER: ${DB_USER:-newdashboard}
DB_PASSWORD: ${DB_PASSWORD:-change_this_password}
DB_NAME: ${DB_NAME:-dashboard}
DB_USER: ${DB_USER:-dashboard}
DB_PASSWORD: ${DB_PASSWORD:-Ket0pr4k}
DB_HOST: database
DB_PORT: "5432"
KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000}
@@ -120,13 +124,14 @@ services:
api:
condition: service_healthy
networks:
- newdashboard-network
- dashboard-cpsp-network
frontend:
image: dashboard-cpsp-frontend
build:
context: .
dockerfile: docker/Dockerfile.web
container_name: newdashboard-frontend
container_name: dashboard-cpsp-frontend
restart: unless-stopped
ports:
- "5002:80"
@@ -134,7 +139,7 @@ services:
api:
condition: service_healthy
networks:
- newdashboard-network
- dashboard-cpsp-network
healthcheck:
test:
["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"]
@@ -144,7 +149,7 @@ services:
start_period: 10s
networks:
newdashboard-network:
dashboard-cpsp-network:
driver: bridge
volumes:
+4 -3
View File
@@ -13,13 +13,14 @@ ALLOWED_HOSTS=localhost,127.0.0.1,api,frontend
CSRF_TRUSTED_ORIGINS=http://localhost:5002,http://127.0.0.1:5002
CORS_ALLOWED_ORIGINS=http://localhost:5002,http://127.0.0.1:5002
WAGTAIL_SITE_NAME=New Dashboard
WAGTAILADMIN_BASE_URL=http://localhost:5002
# Production admin (bootstrap_admin — no demo data)
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n
# Optional production staff (leave BOOTSTRAP_STAFF_USER empty to skip)
BOOTSTRAP_STAFF_USER=staff
BOOTSTRAP_STAFF_PASSWORD=change_me
# Optional: fixed API key for scripts (hashed at rest)
BOOTSTRAP_API_KEY=
+1
View File
@@ -2,5 +2,6 @@
set -e
cd /app
python manage.py migrate --noinput
python manage.py bootstrap_admin
python manage.py collectstatic --noinput
exec gunicorn config.wsgi:application -c config/gunicorn.py
+1 -1
View File
@@ -2,6 +2,6 @@
set -e
cd /app
python manage.py migrate --noinput
printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|DASHBOARD_|WAGTAIL_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \
printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|DASHBOARD_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \
> /etc/environment || true
exec cron -f
-18
View File
@@ -37,24 +37,6 @@ server {
proxy_send_timeout 1800s;
}
location /admin/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /django-admin/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /media/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
+5 -1
View File
@@ -7,6 +7,7 @@ import type {
CycleFilter,
CycleWrite,
Flock,
FlockWrite,
InitialBalanceResult,
Karung,
KarungRequestResult,
@@ -163,8 +164,11 @@ export const api = {
remove: (id: number) => del(`/kandangs/${id}/`),
},
flocks: {
list: async (query?: { cycle_id?: number }) =>
list: async (query?: { kandang_id?: number }) =>
unwrapList(await get<Paginated<Flock> | Flock[]>('/flocks/', query)),
create: (body: FlockWrite) => post<Flock>('/flocks/', body),
update: (id: number, body: Partial<FlockWrite>) => patch<Flock>(`/flocks/${id}/`, body),
remove: (id: number) => del(`/flocks/${id}/`),
},
cycles: {
list: async (query?: CycleFilter) =>
+5 -4
View File
@@ -34,7 +34,7 @@ export type Kandang = {
export type Flock = {
id: number;
flock_name: string;
cycle: number;
kandang: number;
created_at: string;
updated_at: string;
};
@@ -45,8 +45,6 @@ export type Cycle = {
id: number;
kandang: number;
kandang_name: string;
flock: number;
flock_name: string | null;
total_days: number;
current_day: number;
visible_through_date: string;
@@ -221,9 +219,12 @@ export type KandangWrite = {
kandang_name: string;
site: number;
};
export type FlockWrite = {
flock_name: string;
kandang: number;
};
export type CycleWrite = {
kandang: number;
flock?: number;
total_days: number;
current_day?: number;
start_date: string;
+2 -1
View File
@@ -5,9 +5,10 @@ import type { ChickenCounting, Cycle, Karung, KPI } from '../../types/api.ts';
const cycle: Cycle = {
id: 1,
kandang: 9,
flock: 1,
kandang_name: 'Kandang 1',
total_days: 35,
current_day: 2,
visible_through_date: '2026-05-02',
start_date: '2026-05-01',
end_date: '2026-06-04',
chick_in_weight: 45,