remove wagtail and admin website, remove edit option, from hitung ayam, add create flock to settings
This commit is contained in:
1 parent
74dd3faf60
commit
30a736641f
39 files changed
+634
-819
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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,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"]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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}"
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user