Add source_site_id to ActiveSite model and implement unique constraint. Update serializers and views for site registration and claiming. Enhance tests for site registration and claiming functionality.

This commit is contained in:
Alberto-Audrix committed 2026-09-15 09:45:23 +07:00
1 parent 521f36d22d
commit 61f954a3bd
12 files changed
+684 -172

No files matched your search

+8 -3
View File
@@ -92,17 +92,22 @@ class VisibilityScopeTests(TestCase):
self.assertEqual(response.data["results"], [])
def test_registering_active_site_attaches_matching_seed_site(self):
ActiveSite.objects.create(
code="orphan-seed",
name="Orphan Seed",
api_base_url="https://orphan.example/api/v1",
api_key="k",
managed_by=None,
)
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{
"code": "orphan-seed",
"name": "Orphan Seed",
"is_active": True,
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.status_code, 200, response.data)
self.orphan.refresh_from_db()
self.assertEqual(self.orphan.active_site_id, response.data["id"])
listed = self.client.get("/api/v1/sites/")
@@ -0,0 +1,24 @@
# Generated manually: ActiveSite.source_site_id from city Site.site_id
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sync", "0005_rename_registered_by_to_managed_by"),
]
operations = [
migrations.AddField(
model_name="activesite",
name="source_site_id",
field=models.SlugField(
blank=True,
db_index=True,
default="",
help_text="Farm-location Site.site_id (slug) from the registering dashboard",
max_length=64,
),
),
]
@@ -0,0 +1,21 @@
# Unique non-empty source_site_id so one farm site cannot own two ActiveSites.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sync", "0006_activesite_source_site_id"),
]
operations = [
migrations.AddConstraint(
model_name="activesite",
constraint=models.UniqueConstraint(
condition=~models.Q(source_site_id=""),
fields=("source_site_id",),
name="uniq_active_site_source_site_id",
),
),
]
+14
View File
@@ -15,6 +15,13 @@ class ActiveSite(models.Model):
active_site_id = models.BigAutoField(primary_key=True)
code = models.SlugField(max_length=64, unique=True, help_text="Stable id, e.g. sukawarna")
name = models.CharField(max_length=120)
source_site_id = models.SlugField(
max_length=64,
blank=True,
default="",
db_index=True,
help_text="Farm-location Site.site_id (slug) from the registering dashboard",
)
api_base_url = models.URLField(
max_length=255,
blank=True,
@@ -44,6 +51,13 @@ class ActiveSite(models.Model):
class Meta:
db_table = "active_sites"
ordering = ["name"]
constraints = [
models.UniqueConstraint(
fields=["source_site_id"],
condition=~models.Q(source_site_id=""),
name="uniq_active_site_source_site_id",
),
]
def __str__(self) -> str:
return f"{self.name} ({self.code})"
+50
View File
@@ -1,8 +1,16 @@
from django.utils.text import slugify
from rest_framework import serializers
from apps.sync.models import ActiveSite
def _normalize_code(value: str) -> str:
code = slugify(str(value or "").strip())[:64]
if not code:
raise serializers.ValidationError("Kode tidak valid.")
return code
class ActiveSiteSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source="pk", read_only=True)
# Never echo the outbound site API key back to the browser by default.
@@ -17,6 +25,7 @@ class ActiveSiteSerializer(serializers.ModelSerializer):
"id",
"code",
"name",
"source_site_id",
"api_base_url",
"has_api_key",
"managed_by",
@@ -30,6 +39,7 @@ class ActiveSiteSerializer(serializers.ModelSerializer):
]
read_only_fields = [
"id",
"source_site_id",
"has_api_key",
"managed_by",
"managed_by_name",
@@ -58,3 +68,43 @@ class ActiveSiteWriteSerializer(serializers.ModelSerializer):
"is_active",
]
extra_kwargs = {"api_key": {"write_only": True, "required": False}}
class ActiveSiteClaimSerializer(serializers.Serializer):
"""GM claims an ActiveSite that the site dashboard already registered."""
code = serializers.CharField(max_length=64)
def validate_code(self, value: str) -> str:
return _normalize_code(value)
class SiteRegisterSerializer(serializers.Serializer):
"""Payload from a site dashboard registering into the ActiveSite registry."""
code = serializers.CharField(max_length=64)
site_id = serializers.SlugField(max_length=64)
name = serializers.CharField(max_length=120)
api_base_url = serializers.URLField(max_length=255)
api_key = serializers.CharField(max_length=255)
def validate_code(self, value: str) -> str:
return _normalize_code(value)
def validate_site_id(self, value: str) -> str:
site_id = str(value or "").strip()[:64]
if not site_id:
raise serializers.ValidationError("site_id wajib diisi.")
return site_id
def validate_name(self, value: str) -> str:
name = str(value or "").strip()
if not name:
raise serializers.ValidationError("Nama wajib diisi.")
return name[:120]
def validate_api_key(self, value: str) -> str:
key = str(value or "").strip()
if not key:
raise serializers.ValidationError("api_key wajib diisi.")
return key
+206
View File
@@ -0,0 +1,206 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Site
from apps.sync.models import ActiveSite
class RegisterFromSiteTests(TestCase):
def setUp(self):
self.client = APIClient()
self.machine = User.objects.create_user(
user_name="site-bot", password="x", status=User.STATUS_GM
)
self.gm = User.objects.create_user(
user_name="gm1", password="x", status=User.STATUS_GM
)
self.gm_other = User.objects.create_user(
user_name="gm2", password="x", status=User.STATUS_GM
)
def test_register_creates_unclaimed_active_site(self):
self.client.force_authenticate(self.machine)
response = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "Sukawarna!",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://sukawarna.example/api/v1",
"api_key": "site-secret",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["code"], "sukawarna")
self.assertEqual(response.data["name"], "Sukawarna")
self.assertEqual(response.data["source_site_id"], "sukawarna-0001")
self.assertEqual(response.data["api_base_url"], "https://sukawarna.example/api/v1")
self.assertTrue(response.data["has_api_key"])
self.assertIsNone(response.data["managed_by"])
row = ActiveSite.objects.get(code="sukawarna")
self.assertEqual(row.api_key, "site-secret")
self.assertEqual(row.source_site_id, "sukawarna-0001")
self.assertIsNone(row.managed_by_id)
def test_re_register_by_site_id_updates_even_if_code_changes(self):
active = ActiveSite.objects.create(
code="old-code",
name="Old",
source_site_id="sukawarna-0001",
api_base_url="https://old.example/api/v1",
api_key="old-key",
managed_by=self.gm,
)
self.client.force_authenticate(self.machine)
response = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "new-code",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://new.example/api/v1",
"api_key": "new-key",
},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
active.refresh_from_db()
self.assertEqual(active.code, "new-code")
self.assertEqual(active.name, "Sukawarna")
self.assertEqual(active.source_site_id, "sukawarna-0001")
self.assertEqual(active.api_base_url, "https://new.example/api/v1")
self.assertEqual(active.api_key, "new-key")
self.assertEqual(active.managed_by_id, self.gm.pk)
self.assertEqual(ActiveSite.objects.count(), 1)
def test_second_register_same_site_id_never_creates_second_row(self):
self.client.force_authenticate(self.machine)
first = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "code-a",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://a.example/api/v1",
"api_key": "key-a",
},
format="json",
)
self.assertEqual(first.status_code, 201, first.data)
second = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "code-b",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://b.example/api/v1",
"api_key": "key-b",
},
format="json",
)
self.assertEqual(second.status_code, 200, second.data)
self.assertEqual(second.data["id"], first.data["id"])
self.assertEqual(ActiveSite.objects.filter(source_site_id="sukawarna-0001").count(), 1)
self.assertEqual(ActiveSite.objects.count(), 1)
row = ActiveSite.objects.get(source_site_id="sukawarna-0001")
self.assertEqual(row.code, "code-b")
self.assertEqual(row.api_key, "key-b")
def test_re_register_updates_credentials_keeps_claim(self):
active = ActiveSite.objects.create(
code="sukawarna",
name="Old",
source_site_id="sukawarna-0001",
api_base_url="https://old.example/api/v1",
api_key="old-key",
managed_by=self.gm,
)
self.client.force_authenticate(self.machine)
response = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "sukawarna",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://new.example/api/v1",
"api_key": "new-key",
},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
active.refresh_from_db()
self.assertEqual(active.name, "Sukawarna")
self.assertEqual(active.api_base_url, "https://new.example/api/v1")
self.assertEqual(active.api_key, "new-key")
self.assertEqual(active.managed_by_id, self.gm.pk)
class ClaimActiveSiteTests(TestCase):
def setUp(self):
self.client = APIClient()
self.gm = User.objects.create_user(
user_name="gm1", password="x", status=User.STATUS_GM
)
self.gm_other = User.objects.create_user(
user_name="gm2", password="x", status=User.STATUS_GM
)
self.active = ActiveSite.objects.create(
code="sukawarna",
name="Sukawarna",
api_base_url="https://sukawarna.example/api/v1",
api_key="site-secret",
managed_by=None,
)
def test_gm_claims_registered_site_by_code(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{"code": "sukawarna"},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.active.refresh_from_db()
self.assertEqual(self.active.managed_by_id, self.gm.pk)
self.assertTrue(Site.objects.filter(active_site=self.active, user=self.gm).exists())
def test_claim_unknown_code_404(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{"code": "missing"},
format="json",
)
self.assertEqual(response.status_code, 404)
def test_claim_owned_by_other_gm_409(self):
self.active.managed_by = self.gm_other
self.active.save(update_fields=["managed_by"])
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{"code": "sukawarna"},
format="json",
)
self.assertEqual(response.status_code, 409)
def test_hapus_unclaims_without_deleting_active_site(self):
self.active.managed_by = self.gm
self.active.source_site_id = "sukawarna-0001"
self.active.save(update_fields=["managed_by", "source_site_id"])
hq_site = Site.objects.create(
site_name="Sukawarna", user=self.gm, active_site=self.active
)
self.client.force_authenticate(self.gm)
response = self.client.delete(f"/api/v1/active-sites/{self.active.pk}/")
self.assertEqual(response.status_code, 204, response.data)
self.assertTrue(ActiveSite.objects.filter(pk=self.active.pk).exists())
self.active.refresh_from_db()
self.assertIsNone(self.active.managed_by_id)
self.assertEqual(self.active.api_key, "site-secret")
self.assertEqual(self.active.source_site_id, "sukawarna-0001")
hq_site.refresh_from_db()
self.assertIsNone(hq_site.active_site_id)
self.assertTrue(Site.objects.filter(pk=hq_site.pk).exists())
+158 -9
View File
@@ -1,49 +1,198 @@
from rest_framework import status, viewsets
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db import IntegrityError
from apps.accounts.permissions import CanRegisterActiveSite, IsExecutive, can_sync
from apps.accounts.scoping import filter_active_sites_queryset
from apps.farms.services.active_site_registry import ensure_hq_site_for_active_site
from apps.sync.models import ActiveSite
from apps.sync.serializers import ActiveSiteSerializer, ActiveSiteWriteSerializer
from apps.sync.serializers import (
ActiveSiteClaimSerializer,
ActiveSiteSerializer,
ActiveSiteWriteSerializer,
SiteRegisterSerializer,
)
class ActiveSiteViewSet(viewsets.ModelViewSet):
"""
List/read: scoped (director all; BUH managed GMs; GM own).
Create/update/delete: GM and GM Admin.
Create (claim): GM and GM Admin claim a site-registered ActiveSite by code.
Sync: GM and GM Admin only (stubs until mirror pull is implemented).
Creating an active site also attaches (or creates) an HQ Site so farm data
appears in the executive view list.
Site dashboards create/fill ActiveSite via register-from-site (API key auth).
Claiming attaches managed_by and ensures an HQ Site for the view list.
Destroy (Hapus) unclaims only — does not delete the ActiveSite row.
"""
executive_sync_action = True
http_method_names = ["get", "post", "put", "patch", "delete", "head", "options"]
def get_queryset(self):
qs = ActiveSite.objects.select_related("managed_by").all()
return filter_active_sites_queryset(qs, self.request.user)
def get_permissions(self):
if self.action == "register_from_site":
return [permissions.IsAuthenticated()]
if self.action in {"create", "update", "partial_update", "destroy"}:
return [CanRegisterActiveSite()]
return [IsExecutive()]
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
if self.action == "register_from_site":
return SiteRegisterSerializer
if self.action == "create":
return ActiveSiteClaimSerializer
if self.action in {"update", "partial_update"}:
return ActiveSiteWriteSerializer
return ActiveSiteSerializer
def perform_create(self, serializer):
active_site = serializer.save(managed_by=self.request.user)
def create(self, request, *args, **kwargs):
"""GM claim-by-code: site must have registered first."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"]
active_site = ActiveSite.objects.filter(code=code).first()
if active_site is None:
return Response(
{
"detail": (
"Site belum mendaftar dari dasbor site. "
"Minta staf site mendaftar dulu dengan kode ini."
)
},
status=status.HTTP_404_NOT_FOUND,
)
owner_id = active_site.managed_by_id
if owner_id and owner_id != request.user.pk:
return Response(
{"detail": "Kode sudah diklaim oleh GM lain."},
status=status.HTTP_409_CONFLICT,
)
if owner_id != request.user.pk:
active_site.managed_by = request.user
active_site.save(update_fields=["managed_by", "updated_at"])
ensure_hq_site_for_active_site(active_site)
return Response(
ActiveSiteSerializer(active_site).data,
status=status.HTTP_200_OK,
)
def perform_update(self, serializer):
active_site = serializer.save()
if active_site.is_active:
if active_site.is_active and active_site.managed_by_id:
ensure_hq_site_for_active_site(active_site)
def destroy(self, request, *args, **kwargs):
"""
Hapus from daftar tampilan: unclaim only.
Keeps the ActiveSite registry row (credentials / source_site_id) so the
site dashboard registration is not wiped. Clears managed_by and unlinks
any HQ Site.active_site pointer.
"""
from apps.farms.models import Site
active_site = self.get_object()
Site.objects.filter(active_site=active_site).update(active_site=None)
active_site.managed_by = None
active_site.save(update_fields=["managed_by", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["post"], url_path="register-from-site")
def register_from_site(self, request):
"""
Machine endpoint: site dashboard creates/updates ActiveSite credentials.
Does not set managed_by (GM claim is separate).
One farm site_id maps to at most one ActiveSite. Re-register with a new
code updates that row; it never inserts a second ActiveSite.
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
code = data["code"]
source_site_id = data["site_id"]
active_site = ActiveSite.objects.filter(source_site_id=source_site_id).first()
if active_site is None:
by_code = ActiveSite.objects.filter(code=code).first()
if by_code is not None:
if by_code.source_site_id and by_code.source_site_id != source_site_id:
return Response(
{"detail": "Kode sudah dipakai site lain."},
status=status.HTTP_409_CONFLICT,
)
# Legacy row missing source_site_id: attach this site to it.
active_site = by_code
if active_site is None:
try:
active_site = ActiveSite.objects.create(
code=code,
name=data["name"],
source_site_id=source_site_id,
api_base_url=data["api_base_url"],
api_key=data["api_key"],
managed_by=None,
is_active=True,
)
except IntegrityError:
# Concurrent register for same source_site_id or code.
active_site = ActiveSite.objects.filter(
source_site_id=source_site_id
).first() or ActiveSite.objects.filter(code=code).first()
if active_site is None:
return Response(
{"detail": "Gagal mendaftarkan site — coba lagi."},
status=status.HTTP_409_CONFLICT,
)
else:
return Response(
ActiveSiteSerializer(active_site).data,
status=status.HTTP_201_CREATED,
)
if (
code != active_site.code
and ActiveSite.objects.filter(code=code).exclude(pk=active_site.pk).exists()
):
return Response(
{"detail": "Kode sudah dipakai site lain."},
status=status.HTTP_409_CONFLICT,
)
active_site.code = code
active_site.name = data["name"]
active_site.source_site_id = source_site_id
active_site.api_base_url = data["api_base_url"]
active_site.api_key = data["api_key"]
active_site.is_active = True
try:
active_site.save(
update_fields=[
"code",
"name",
"source_site_id",
"api_base_url",
"api_key",
"is_active",
"updated_at",
]
)
except IntegrityError:
return Response(
{"detail": "Site atau kode bentrok dengan pendaftaran lain."},
status=status.HTTP_409_CONFLICT,
)
return Response(ActiveSiteSerializer(active_site).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], url_path="sync")
def sync(self, request, pk=None):
if not can_sync(request.user):
+189 -120
View File
@@ -1,15 +1,20 @@
-- =============================================================================
-- PostgreSQL Schema — CPSP Dashboard ERD
-- Source: ERD_dashboard.png (updated 2026-08-20)
-- PostgreSQL Schema — CPSP Executive Dashboard ERD
-- Source: live DB (dashboard-cpsp-executive) + Django models (updated 2026-09-14)
--
-- user_access.status:
-- inactive | gm | gm_admin | buh | bu_admin | director | director_admin |
-- account_admin | superuser
-- (Django syncs is_active/is_superuser from status on save:
-- inactive => both false; superuser => both true; other executive roles =>
-- is_active true, is_superuser false. Product roles never use is_staff.)
--
-- user_access.status: active | inactive | superadmin
-- (Django syncs is_active/is_staff/is_superuser from status on save:
-- inactive => all false; superadmin => is_active/is_superuser true,
-- is_staff false; active => is_active true, is_superuser false, is_staff
-- left untouched so operators can be marked staff)
-- user_access → sites → kandang → cycles
-- cycles → flock (flock.cycle_id, required; cycles has no flock_id)
-- flock → iot_panel
-- kandang → flock → iot_panel
-- user_access → api_keys
-- user_access → active_sites (managed_by)
-- sites ↔ active_sites (optional 1:1)
-- cycles → active_sites (mirror source) + close-approval user FKs
-- cycles → ai_insight, feed_sacks, chicken_counting, chicken_weight,
-- manual_input, kpi
-- kpi → chicken_counting, chicken_weight, feed_sacks, manual_input
@@ -17,120 +22,184 @@
-- -----------------------------------------------------------------------------
-- 1. User Access
-- Django AbstractBaseUser + PermissionsMixin. `status` is the app's role
-- (active | inactive | superadmin); Django auth flags are synced from it on
-- save (is_staff stays untouched for active users, superadmin => is_staff
-- false, is_superuser true).
-- Django AbstractBaseUser + PermissionsMixin. `status` is the executive
-- role; is_active/is_superuser are derived from it on save.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS user_access (
user_id SERIAL PRIMARY KEY,
user_name VARCHAR(30) NOT NULL UNIQUE,
display_name VARCHAR(30) NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL,
last_login TIMESTAMP WITH TIME ZONE,
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(30) NOT NULL
user_id BIGSERIAL PRIMARY KEY,
user_name VARCHAR(30) NOT NULL UNIQUE,
display_name VARCHAR(30) NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL,
last_login TIMESTAMP WITH TIME ZONE,
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
status VARCHAR(30) NOT NULL DEFAULT 'gm',
managed_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
managed_by_pending_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
managed_by_requested_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_access_status ON user_access (status);
CREATE INDEX IF NOT EXISTS idx_user_access_managed_by_id ON user_access (managed_by_id);
CREATE INDEX IF NOT EXISTS idx_user_access_managed_by_pending_id ON user_access (managed_by_pending_id);
-- Django PermissionsMixin many-to-many tables.
CREATE TABLE IF NOT EXISTS user_access_groups (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES auth_group (id) ON DELETE CASCADE
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES auth_group (id) ON DELETE CASCADE,
UNIQUE (user_id, group_id)
);
CREATE TABLE IF NOT EXISTS user_access_user_permissions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
permission_id INTEGER NOT NULL REFERENCES auth_permission (id) ON DELETE CASCADE
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
permission_id INTEGER NOT NULL REFERENCES auth_permission (id) ON DELETE CASCADE,
UNIQUE (user_id, permission_id)
);
-- -----------------------------------------------------------------------------
-- 2. Sites
-- 2. API Keys (inbound X-API-Key auth to HQ)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS api_keys (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
prefix VARCHAR(12) NOT NULL,
key_hash VARCHAR(64) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys (user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys (prefix);
-- -----------------------------------------------------------------------------
-- 3. Active Sites (farm-location dashboard-cpsp registry)
-- source_site_id = farm-location sites.site_id slug (e.g. sukawarna-4821),
-- sent by site dashboard on register-from-site (API field name: site_id).
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS active_sites (
active_site_id BIGSERIAL PRIMARY KEY,
code VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL,
source_site_id VARCHAR(64) NOT NULL DEFAULT '',
api_base_url VARCHAR(255) NOT NULL DEFAULT '',
api_key VARCHAR(255) NOT NULL DEFAULT '',
managed_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_synced_at TIMESTAMP WITH TIME ZONE,
last_sync_status VARCHAR(32) NOT NULL DEFAULT '',
last_sync_message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_active_sites_managed_by_id ON active_sites (managed_by_id);
CREATE INDEX IF NOT EXISTS idx_active_sites_source_site_id ON active_sites (source_site_id);
-- -----------------------------------------------------------------------------
-- 4. Sites
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sites (
site_id SERIAL PRIMARY KEY,
site_name VARCHAR(30) NOT NULL,
user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
site_id BIGSERIAL PRIMARY KEY,
site_name VARCHAR(30) NOT NULL,
user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
active_site_id BIGINT UNIQUE REFERENCES active_sites (active_site_id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sites_user_id ON sites (user_id);
-- -----------------------------------------------------------------------------
-- 3. Kandang (Coop / Pen)
-- 5. Kandang (Coop / Pen)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS kandang (
kandang_id SERIAL PRIMARY KEY,
kandang_name VARCHAR(30) NOT NULL,
site_id INTEGER NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
kandang_id BIGSERIAL PRIMARY KEY,
kandang_name VARCHAR(30) NOT NULL,
feed_in_button_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
site_id BIGINT NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_kandang_site_id ON kandang (site_id);
-- -----------------------------------------------------------------------------
-- 4. Cycles
-- No flock_id — flock belongs to cycle via flock.cycle_id.
-- 6. Flock
-- Belongs to kandang (not cycle). IoT readings attach here.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS flock (
flock_id BIGSERIAL PRIMARY KEY,
flock_name VARCHAR(30) NOT NULL,
kandang_id BIGINT NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_flock_kandang_id ON flock (kandang_id);
-- -----------------------------------------------------------------------------
-- 7. Cycles
-- end_date NULL while open; status active | pending_close | closed.
-- Optional active_site + source_cycle_id identify the mirrored farm cycle.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS cycles (
cycle_id SERIAL PRIMARY KEY,
total_days INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
doc_in_weight INTEGER NOT NULL,
doc_in_count INTEGER NOT NULL,
feed_initial_balance INTEGER NOT NULL DEFAULT 0,
feed_initial_balance_date DATE,
status VARCHAR(30) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
kandang_id INTEGER NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
CONSTRAINT chk_cycles_date_range CHECK (end_date >= start_date)
cycle_id BIGSERIAL PRIMARY KEY,
total_days INTEGER NOT NULL DEFAULT 1,
start_date DATE NOT NULL,
end_date DATE,
doc_in_weight INTEGER NOT NULL,
doc_in_count INTEGER NOT NULL,
feed_initial_balance INTEGER NOT NULL DEFAULT 0,
feed_initial_balance_date DATE,
status VARCHAR(30) NOT NULL DEFAULT 'active',
kandang_id BIGINT NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
active_site_id BIGINT REFERENCES active_sites (active_site_id) ON DELETE SET NULL,
source_cycle_id BIGINT,
proposed_end_date DATE,
close_requested_at TIMESTAMP WITH TIME ZONE,
close_requested_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
close_approved_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
close_approved_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_cycles_date_range CHECK (end_date IS NULL OR end_date >= start_date)
);
CREATE INDEX IF NOT EXISTS idx_cycles_kandang_id ON cycles (kandang_id);
CREATE INDEX IF NOT EXISTS idx_cycles_status ON cycles (status);
CREATE INDEX IF NOT EXISTS idx_cycles_start_date ON cycles (start_date);
CREATE INDEX IF NOT EXISTS idx_cycles_active_site_id ON cycles (active_site_id);
CREATE INDEX IF NOT EXISTS idx_cycles_close_requested_by_id ON cycles (close_requested_by_id);
CREATE INDEX IF NOT EXISTS idx_cycles_close_approved_by_id ON cycles (close_approved_by_id);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_cycle_active_source
ON cycles (active_site_id, source_cycle_id)
WHERE active_site_id IS NOT NULL AND source_cycle_id IS NOT NULL;
-- -----------------------------------------------------------------------------
-- 5. Flock
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS flock (
flock_id SERIAL PRIMARY KEY,
flock_name VARCHAR(30) NOT NULL,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_flock_cycle_id ON flock (cycle_id);
-- -----------------------------------------------------------------------------
-- 6. IoT Panel
-- 8. IoT Panel
-- One row per sensor reading; the panel polls every 10 minutes, so a cycle
-- day holds up to 144 rows. `timestamp` carries the reading time; `date` is
-- kept denormalized for fast daily grouping.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS iot_panel (
panel_id SERIAL PRIMARY KEY,
panel_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE,
wind_speed FLOAT NOT NULL,
humidity FLOAT NOT NULL,
water_total FLOAT NOT NULL,
average_temperature FLOAT NOT NULL,
experience_temperature FLOAT NOT NULL,
wind_speed DOUBLE PRECISION NOT NULL DEFAULT 0,
humidity DOUBLE PRECISION NOT NULL DEFAULT 0,
water_total DOUBLE PRECISION NOT NULL DEFAULT 0,
average_temperature DOUBLE PRECISION NOT NULL DEFAULT 0,
experience_temperature DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
flock_id INTEGER NOT NULL REFERENCES flock (flock_id) ON DELETE CASCADE
flock_id BIGINT NOT NULL REFERENCES flock (flock_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_iot_panel_flock_id ON iot_panel (flock_id);
@@ -139,10 +208,10 @@ CREATE INDEX IF NOT EXISTS idx_iot_panel_timestamp ON iot_panel (timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_iot_panel_flock_timestamp ON iot_panel (flock_id, timestamp);
-- -----------------------------------------------------------------------------
-- 7. AI Insight
-- 9. AI Insight
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ai_insight (
id SERIAL PRIMARY KEY,
id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
insight_text TEXT NOT NULL,
alert VARCHAR(100) NOT NULL,
@@ -150,17 +219,17 @@ CREATE TABLE IF NOT EXISTS ai_insight (
session VARCHAR(100) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id);
CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date);
-- -----------------------------------------------------------------------------
-- 8. Feed Sacks
-- 10. Feed Sacks
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS feed_sacks (
fs_id SERIAL PRIMARY KEY,
fs_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
in_today INTEGER NOT NULL DEFAULT 0,
out_today INTEGER NOT NULL DEFAULT 0,
@@ -170,7 +239,7 @@ CREATE TABLE IF NOT EXISTS feed_sacks (
feed_use_total INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_feed_sacks_cycle_id ON feed_sacks (cycle_id);
@@ -178,16 +247,16 @@ CREATE INDEX IF NOT EXISTS idx_feed_sacks_date ON feed_sacks (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_feed_sacks_cycle_date ON feed_sacks (cycle_id, date);
-- -----------------------------------------------------------------------------
-- 9. Chicken Counting
-- 11. Chicken Counting
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chicken_counting (
cc_id SERIAL PRIMARY KEY,
cc_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0,
mortality_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_chicken_counting_cycle_id ON chicken_counting (cycle_id);
@@ -195,31 +264,31 @@ CREATE INDEX IF NOT EXISTS idx_chicken_counting_date ON chicken_counting (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_chicken_counting_cycle_date ON chicken_counting (cycle_id, date);
-- -----------------------------------------------------------------------------
-- 10. Chicken Weight
-- 12. Chicken Weight
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chicken_weight (
cw_id SERIAL PRIMARY KEY,
cw_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
age INTEGER NOT NULL,
doc_weight INTEGER NOT NULL,
average_weight FLOAT NOT NULL,
average_weight DOUBLE PRECISION NOT NULL,
chicken_count INTEGER NOT NULL,
uniformity FLOAT NOT NULL,
average_daily_gain FLOAT NOT NULL,
uniformity DOUBLE PRECISION NOT NULL,
average_daily_gain DOUBLE PRECISION NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_chicken_weight_cycle_id ON chicken_weight (cycle_id);
CREATE INDEX IF NOT EXISTS idx_chicken_weight_date ON chicken_weight (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_chicken_weight_cycle_date ON chicken_weight (cycle_id, date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_cw_cycle_date ON chicken_weight (cycle_id, date);
-- -----------------------------------------------------------------------------
-- 11. Manual Input
-- 13. Manual Input
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS manual_input (
manual_id SERIAL PRIMARY KEY,
manual_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
age_manual INTEGER NOT NULL DEFAULT 0,
mortality_manual INTEGER NOT NULL DEFAULT 0,
@@ -232,16 +301,16 @@ CREATE TABLE IF NOT EXISTS manual_input (
feed_out_manual_total INTEGER NOT NULL DEFAULT 0,
harvest_manual INTEGER NOT NULL DEFAULT 0,
harvest_manual_total INTEGER NOT NULL DEFAULT 0,
harvest_weight_manual FLOAT NOT NULL DEFAULT 0,
harvest_weight_manual_total FLOAT NOT NULL DEFAULT 0,
manual_weight FLOAT NOT NULL DEFAULT 0,
average_harvest_day_manual FLOAT NOT NULL DEFAULT 0,
average_harvest_weight_manual FLOAT NOT NULL DEFAULT 0,
fcr_manual FLOAT NOT NULL DEFAULT 0,
eef_manual FLOAT NOT NULL DEFAULT 0,
harvest_weight_manual DOUBLE PRECISION NOT NULL DEFAULT 0,
harvest_weight_manual_total DOUBLE PRECISION NOT NULL DEFAULT 0,
manual_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
average_harvest_day_manual DOUBLE PRECISION NOT NULL DEFAULT 0,
average_harvest_weight_manual DOUBLE PRECISION NOT NULL DEFAULT 0,
fcr_manual DOUBLE PRECISION NOT NULL DEFAULT 0,
eef_manual DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_manual_input_cycle_id ON manual_input (cycle_id);
@@ -249,10 +318,10 @@ CREATE INDEX IF NOT EXISTS idx_manual_input_date ON manual_input (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_manual_input_cycle_date ON manual_input (cycle_id, date);
-- -----------------------------------------------------------------------------
-- 12. KPI (aggregates data from cycle + source records)
-- 14. KPI (aggregates data from cycle + source records)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS kpi (
kpi_id SERIAL PRIMARY KEY,
kpi_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
age INTEGER NOT NULL DEFAULT 0,
mortality INTEGER NOT NULL DEFAULT 0,
@@ -261,22 +330,22 @@ CREATE TABLE IF NOT EXISTS kpi (
feed_total INTEGER NOT NULL DEFAULT 0,
harvest INTEGER NOT NULL DEFAULT 0,
harvest_total INTEGER NOT NULL DEFAULT 0,
harvest_weight FLOAT NOT NULL DEFAULT 0,
harvest_weight_total FLOAT NOT NULL DEFAULT 0,
harvest_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
harvest_weight_total DOUBLE PRECISION NOT NULL DEFAULT 0,
chicken_life INTEGER NOT NULL DEFAULT 0,
chicken_life_percentage FLOAT NOT NULL DEFAULT 0,
iot_weight FLOAT NOT NULL DEFAULT 0,
average_harvest_day FLOAT NOT NULL DEFAULT 0,
average_harvest_weight FLOAT NOT NULL DEFAULT 0,
fcr FLOAT NOT NULL DEFAULT 0,
eef FLOAT NOT NULL DEFAULT 0,
chicken_life_percentage DOUBLE PRECISION NOT NULL DEFAULT 0,
iot_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
average_harvest_day DOUBLE PRECISION NOT NULL DEFAULT 0,
average_harvest_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
fcr DOUBLE PRECISION NOT NULL DEFAULT 0,
eef DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE,
cc_id INTEGER NOT NULL REFERENCES chicken_counting (cc_id) ON DELETE RESTRICT,
cw_id INTEGER NOT NULL REFERENCES chicken_weight (cw_id) ON DELETE RESTRICT,
fs_id INTEGER NOT NULL REFERENCES feed_sacks (fs_id) ON DELETE RESTRICT,
manual_id INTEGER NOT NULL REFERENCES manual_input (manual_id) ON DELETE RESTRICT
cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE,
cc_id BIGINT NOT NULL REFERENCES chicken_counting (cc_id) ON DELETE RESTRICT,
cw_id BIGINT NOT NULL REFERENCES chicken_weight (cw_id) ON DELETE RESTRICT,
fs_id BIGINT NOT NULL REFERENCES feed_sacks (fs_id) ON DELETE RESTRICT,
manual_id BIGINT NOT NULL REFERENCES manual_input (manual_id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_kpi_cycle_id ON kpi (cycle_id);
@@ -299,8 +368,8 @@ DECLARE
tbl TEXT;
BEGIN
FOREACH tbl IN ARRAY ARRAY[
'user_access', 'sites', 'kandang', 'cycles', 'flock',
'iot_panel', 'ai_insight', 'feed_sacks', 'chicken_counting',
'user_access', 'api_keys', 'active_sites', 'sites', 'kandang', 'flock',
'cycles', 'iot_panel', 'ai_insight', 'feed_sacks', 'chicken_counting',
'chicken_weight', 'manual_input', 'kpi'
]
LOOP
+8 -31
View File
@@ -221,9 +221,7 @@ const GmViewList: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [codeTouched, setCodeTouched] = useState(false);
const flash = (msg: string) => {
setNotice(msg);
@@ -258,9 +256,7 @@ const GmViewList: React.FC = () => {
}, [reload]);
const resetForm = () => {
setName('');
setCode('');
setCodeTouched(false);
};
const wrap = async (fn: () => Promise<void>) => {
@@ -310,7 +306,7 @@ const GmViewList: React.FC = () => {
<div>
<PageHeader
title="Pengaturan"
subtitle="Pilih BUH Anda, lalu daftarkan site dengan nama dan kode"
subtitle="Pilih BUH Anda, lalu klaim site dengan kode dari dasbor site"
/>
{error && (
<div className="mb-4">
@@ -396,57 +392,38 @@ const GmViewList: React.FC = () => {
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-1">Tambah site</h2>
<p className="text-xs text-gray-500 mb-4">
Masukkan nama dan kode yang sama dengan yang diisi manajer di dasbor site, agar site
terhubung ke daftar tampilan Anda.
Masukkan kode yang sama dengan yang dipakai staf saat daftar ke pusat di dasbor site.
Site harus sudah mendaftar dulu sebelum bisa masuk daftar tampilan Anda.
</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
const resolvedCode = code || slugify(name);
const resolvedCode = slugify(code);
if (!resolvedCode) return;
void wrap(async () => {
const created = await api.activeSites.create({
const claimed = await api.activeSites.create({
code: resolvedCode,
name: name.trim(),
is_active: true,
});
resetForm();
flash(`Site ${created.name} ditambahkan ke daftar tampilan.`);
flash(`Site ${claimed.name} ditambahkan ke daftar tampilan.`);
});
}}
>
<label className="block text-sm text-gray-700">
Nama site
<input
className={`${inputClass} mt-1`}
placeholder="Contoh: Sukawarna"
value={name}
onChange={(e) => {
const next = e.target.value;
setName(next);
if (!codeTouched) setCode(slugify(next));
}}
required
/>
</label>
<label className="block text-sm text-gray-700">
Kode
<input
className={`${inputClass} mt-1`}
placeholder="sukawarna"
value={code}
onChange={(e) => {
setCodeTouched(true);
setCode(slugify(e.target.value));
}}
onChange={(e) => setCode(slugify(e.target.value))}
required
/>
</label>
<button
type="submit"
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50"
disabled={saving}
disabled={saving || !code}
>
{saving ? 'Menyimpan...' : 'Tambah ke daftar'}
</button>
+2 -2
View File
@@ -87,7 +87,7 @@ const UserManagementPage: React.FC = () => {
password,
});
setNotice(
`Akun "${userName.trim()}" dibuat. Bagikan password sementara secara aman (di luar sistem).`
`Akun "${userName.trim()}" dibuat. Jangan lupa ganti password setelah login pertama kali.`
);
setUserLogin('');
setUserName('');
@@ -133,7 +133,7 @@ const UserManagementPage: React.FC = () => {
setBusyId(id);
try {
await api.users.setPassword(id, resetPassword);
setNotice('Password direset. Bagikan password baru secara aman (di luar sistem).');
setNotice('Password direset. Bagikan password baru secara aman.');
setResetId(null);
setResetPassword('');
await reload();
+2 -6
View File
@@ -156,7 +156,7 @@ describe('SettingsPage city site registry', () => {
render(<SettingsPage />);
await screen.findByText('Sukawarna');
fireEvent.change(screen.getByPlaceholderText('Contoh: Sukawarna'), {
fireEvent.change(screen.getByPlaceholderText('sukawarna'), {
target: { value: 'Cikupa' },
});
fireEvent.click(screen.getByRole('button', { name: 'Tambah ke daftar' }));
@@ -164,12 +164,8 @@ describe('SettingsPage city site registry', () => {
await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1));
expect(createMock).toHaveBeenCalledWith({
code: 'cikupa',
name: 'Cikupa',
is_active: true,
});
});
it('lets GM request a BUH', async () => {
}); it('lets GM request a BUH', async () => {
requestMock.mockResolvedValue({
...authState.user,
managed_by_pending: 2,
+2 -1
View File
@@ -43,6 +43,7 @@ export type ActiveSite = {
id: number;
code: string;
name: string;
source_site_id: string;
api_base_url: string;
has_api_key: boolean;
managed_by?: number | null;
@@ -57,7 +58,7 @@ export type ActiveSite = {
export type ActiveSiteWrite = {
code: string;
name: string;
name?: string;
api_base_url?: string;
api_key?: string;
is_active?: boolean;