add feed in / out button page and also button to redirect to button page
This commit is contained in:
1 parent
07b8e2dc25
commit
b86bd7a2f4
17 files changed
+719
-28
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext.tsx';
|
||||
import { FarmProvider } from './context/FarmContext.tsx';
|
||||
import AuthPage from './components/auth/AuthPage.tsx';
|
||||
import FeedInButtonPanel from './components/feedInButton/FeedInButtonPanel.tsx';
|
||||
import { Banner, Spinner } from './components/ui/Feedback.tsx';
|
||||
import { useFarm } from './context/FarmContext.tsx';
|
||||
|
||||
const ButtonContent: React.FC = () => {
|
||||
const { error } = useFarm();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex flex-col">
|
||||
<header className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="max-w-5xl mx-auto flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src="/favicon.png" alt="Logo" className="h-10 object-contain" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">Feed In/Out Button</h1>
|
||||
<p className="text-sm text-gray-500">Pilih kandang untuk memulai hitung feed in</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 hover:text-red-600 shrink-0"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
Kembali ke Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 px-6 py-10">
|
||||
{error && (
|
||||
<div className="max-w-5xl mx-auto mb-6">
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
<FeedInButtonPanel variant="kiosk" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Gate: React.FC = () => {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) return <AuthPage />;
|
||||
return (
|
||||
<FarmProvider>
|
||||
<ButtonContent />
|
||||
</FarmProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const ButtonApp: React.FC = () => (
|
||||
<AuthProvider>
|
||||
<Gate />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
export default ButtonApp;
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("farms", "0012_cycle_feed_initial_balance_date"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="kandang",
|
||||
name="feed_in_button_url",
|
||||
field=models.URLField(blank=True, default="", max_length=500),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_single_url_to_list(apps, schema_editor):
|
||||
Kandang = apps.get_model("farms", "Kandang")
|
||||
for kandang in Kandang.objects.exclude(feed_in_button_url="").exclude(feed_in_button_url__isnull=True):
|
||||
url = (kandang.feed_in_button_url or "").strip()
|
||||
if url:
|
||||
kandang.feed_in_button_urls = [{"label": "", "url": url}]
|
||||
kandang.save(update_fields=["feed_in_button_urls"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("farms", "0013_kandang_feed_in_button_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="kandang",
|
||||
name="feed_in_button_urls",
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
migrations.RunPython(migrate_single_url_to_list, migrations.RunPython.noop),
|
||||
migrations.RemoveField(
|
||||
model_name="kandang",
|
||||
name="feed_in_button_url",
|
||||
),
|
||||
]
|
||||
@@ -24,6 +24,7 @@ class Site(models.Model):
|
||||
class Kandang(models.Model):
|
||||
kandang_id = models.BigAutoField(primary_key=True)
|
||||
kandang_name = models.CharField(max_length=30)
|
||||
feed_in_button_urls = models.JSONField(default=list, blank=True)
|
||||
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="kandangs")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -11,12 +11,56 @@ class SiteSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class FeedInButtonUrlSerializer(serializers.Serializer):
|
||||
label = serializers.CharField(max_length=50, required=False, allow_blank=True, default="")
|
||||
url = serializers.URLField(max_length=500)
|
||||
|
||||
|
||||
class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
feed_in_button_urls = FeedInButtonUrlSerializer(many=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Kandang
|
||||
fields = ["id", "kandang_name", "site", "created_at", "updated_at"]
|
||||
fields = [
|
||||
"id",
|
||||
"kandang_name",
|
||||
"feed_in_button_urls",
|
||||
"site",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def validate_feed_in_button_urls(self, value):
|
||||
cleaned = []
|
||||
for item in value or []:
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not url:
|
||||
continue
|
||||
label = str(item.get("label", "") or "").strip()[:50]
|
||||
cleaned.append({"label": label, "url": url})
|
||||
return cleaned
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
urls = validated_data.pop("feed_in_button_urls", None)
|
||||
instance = super().update(instance, validated_data)
|
||||
if urls is not None:
|
||||
instance.feed_in_button_urls = urls
|
||||
instance.save(update_fields=["feed_in_button_urls", "updated_at"])
|
||||
return instance
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
urls = data.get("feed_in_button_urls") or []
|
||||
if not isinstance(urls, list):
|
||||
urls = []
|
||||
data["feed_in_button_urls"] = [
|
||||
{"label": str(item.get("label", "") or ""), "url": str(item.get("url", "") or "")}
|
||||
for item in urls
|
||||
if isinstance(item, dict) and str(item.get("url", "") or "").strip()
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from django.test import Client, TestCase
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.farms.models import Kandang, Site
|
||||
|
||||
|
||||
class KandangFeedInButtonUrlTests(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create_user(user_name="owner", password="secret")
|
||||
self.admin = User.objects.create_user(
|
||||
user_name="admin",
|
||||
password="admin123",
|
||||
status=User.STATUS_SUPERADMIN,
|
||||
is_superuser=True,
|
||||
)
|
||||
self.staff = User.objects.create_user(
|
||||
user_name="staff",
|
||||
password="staff123",
|
||||
status=User.STATUS_ACTIVE,
|
||||
is_staff=True,
|
||||
)
|
||||
self.site = Site.objects.create(site_name="Site A", user=self.owner)
|
||||
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
|
||||
self.payload = {
|
||||
"feed_in_button_urls": [{"label": "Gudang A", "url": "http://192.168.1.10:8080/"}]
|
||||
}
|
||||
|
||||
def _csrf_headers(self, client: Client) -> dict[str, str]:
|
||||
token = client.cookies["csrftoken"].value
|
||||
return {"HTTP_X_CSRFTOKEN": token}
|
||||
|
||||
def _login(self, client: Client, user_name: str, password: str) -> None:
|
||||
client.get("/api/v1/auth/csrf/")
|
||||
response = client.post(
|
||||
"/api/v1/auth/login/",
|
||||
{"user_name": user_name, "password": password},
|
||||
content_type="application/json",
|
||||
**self._csrf_headers(client),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
|
||||
def test_superadmin_can_save_feed_in_button_urls(self):
|
||||
client = Client(enforce_csrf_checks=True)
|
||||
self._login(client, "admin", "admin123")
|
||||
response = client.patch(
|
||||
f"/api/v1/kandangs/{self.kandang.pk}/",
|
||||
self.payload,
|
||||
content_type="application/json",
|
||||
**self._csrf_headers(client),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.kandang.refresh_from_db()
|
||||
self.assertEqual(self.kandang.feed_in_button_urls, self.payload["feed_in_button_urls"])
|
||||
|
||||
def test_staff_cannot_save_feed_in_button_urls(self):
|
||||
client = Client(enforce_csrf_checks=True)
|
||||
self._login(client, "staff", "staff123")
|
||||
response = client.patch(
|
||||
f"/api/v1/kandangs/{self.kandang.pk}/",
|
||||
self.payload,
|
||||
content_type="application/json",
|
||||
**self._csrf_headers(client),
|
||||
)
|
||||
self.assertEqual(response.status_code, 403, response.content)
|
||||
self.kandang.refresh_from_db()
|
||||
self.assertEqual(self.kandang.feed_in_button_urls, [])
|
||||
|
||||
def test_staff_cannot_rename_kandang(self):
|
||||
client = Client(enforce_csrf_checks=True)
|
||||
self._login(client, "staff", "staff123")
|
||||
response = client.patch(
|
||||
f"/api/v1/kandangs/{self.kandang.pk}/",
|
||||
{"kandang_name": "Renamed"},
|
||||
content_type="application/json",
|
||||
**self._csrf_headers(client),
|
||||
)
|
||||
self.assertEqual(response.status_code, 403, response.content)
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Feed In/Out Button</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/button/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import ButtonApp from '../ButtonApp.tsx';
|
||||
import ErrorBoundary from '../components/common/ErrorBoundary.tsx';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Could not find root element to mount to');
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ButtonApp />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -98,6 +98,14 @@ const Header: React.FC<HeaderProps> = ({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<a
|
||||
href="/button/"
|
||||
title="Feed In/Out Button"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 border border-red-200 rounded-md bg-red-50 text-sm font-semibold text-red-700 hover:bg-red-100 hover:border-red-300"
|
||||
>
|
||||
<i className="fa-solid fa-hand-pointer" />
|
||||
<span className="hidden sm:inline">Feed In/Out Button</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center ml-auto">
|
||||
<button
|
||||
|
||||
+101
-1
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import { useAuth } from '../context/AuthContext.tsx';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import type { Cycle } from '../types/api.ts';
|
||||
import type { Cycle, FeedInButtonUrl } from '../types/api.ts';
|
||||
import { Banner, PageHeader } from './ui/Feedback.tsx';
|
||||
import { addDaysIso, formatDateId, inclusiveDayCount, resolveCycleStatus, todayIso } from '../utils/format.ts';
|
||||
|
||||
@@ -23,6 +23,8 @@ const cycleToForm = (cycle: Cycle) => ({
|
||||
chickInWeight: cycle.chick_in_weight,
|
||||
});
|
||||
|
||||
const emptyFeedInButtonUrl = (): FeedInButtonUrl => ({ label: '', url: '' });
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
@@ -42,6 +44,7 @@ const SettingsPage: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [siteName, setSiteName] = useState('');
|
||||
const [kandangName, setKandangName] = useState('');
|
||||
const [feedInButtonUrls, setFeedInButtonUrls] = useState<FeedInButtonUrl[]>([emptyFeedInButtonUrl()]);
|
||||
const [flockName, setFlockName] = useState('');
|
||||
const [editingCycleId, setEditingCycleId] = useState<number | null>(null);
|
||||
const [startDate, setStartDate] = useState(defaultCycleForm().startDate);
|
||||
@@ -84,6 +87,13 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
const isSuperAdmin = user?.status === 'superadmin';
|
||||
|
||||
const selectedKandang = kandangs.find((k) => k.id === selectedKandangId) ?? null;
|
||||
|
||||
React.useEffect(() => {
|
||||
const urls = selectedKandang?.feed_in_button_urls ?? [];
|
||||
setFeedInButtonUrls(urls.length > 0 ? urls.map((entry) => ({ ...entry })) : [emptyFeedInButtonUrl()]);
|
||||
}, [selectedKandang?.id, selectedKandang?.feed_in_button_urls]);
|
||||
|
||||
const wrap = async (fn: () => Promise<void>) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -234,6 +244,96 @@ const SettingsPage: React.FC = () => {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{selectedKandang && isSuperAdmin && (
|
||||
<form
|
||||
className="mt-4 pt-4 border-t space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void wrap(async () => {
|
||||
const payload = feedInButtonUrls
|
||||
.map((entry) => ({
|
||||
label: entry.label.trim(),
|
||||
url: entry.url.trim(),
|
||||
}))
|
||||
.filter((entry) => entry.url);
|
||||
await api.kandangs.update(selectedKandang.id, {
|
||||
feed_in_button_urls: payload,
|
||||
});
|
||||
flash(`URL Feed In/Out Button untuk ${selectedKandang.kandang_name} disimpan.`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">URL Feed In/Out Button</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Tambahkan satu atau lebih alamat hitung feed in untuk kandang ini.
|
||||
</p>
|
||||
</div>
|
||||
{feedInButtonUrls.map((entry, index) => (
|
||||
<div key={index} className="rounded-lg border border-gray-200 p-3 space-y-2">
|
||||
<label className="block text-sm">
|
||||
Label (opsional)
|
||||
<input
|
||||
className={`${inputClass} mt-1`}
|
||||
placeholder="Contoh: Gudang A"
|
||||
value={entry.label}
|
||||
onChange={(e) =>
|
||||
setFeedInButtonUrls((rows) =>
|
||||
rows.map((row, rowIndex) =>
|
||||
rowIndex === index ? { ...row, label: e.target.value } : row
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
URL
|
||||
<input
|
||||
className={`${inputClass} mt-1`}
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
value={entry.url}
|
||||
onChange={(e) =>
|
||||
setFeedInButtonUrls((rows) =>
|
||||
rows.map((row, rowIndex) =>
|
||||
rowIndex === index ? { ...row, url: e.target.value } : row
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{feedInButtonUrls.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-500 hover:underline"
|
||||
onClick={() =>
|
||||
setFeedInButtonUrls((rows) => rows.filter((_, rowIndex) => rowIndex !== index))
|
||||
}
|
||||
>
|
||||
Hapus URL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="border border-gray-300 text-gray-700 px-3 py-2 rounded-lg text-sm font-semibold hover:bg-gray-50"
|
||||
onClick={() => setFeedInButtonUrls((rows) => [...rows, emptyFeedInButtonUrl()])}
|
||||
>
|
||||
Tambah URL
|
||||
</button>
|
||||
<button
|
||||
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
|
||||
disabled={saving}
|
||||
>
|
||||
Simpan URL
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="font-semibold text-gray-800 mb-3">Flock</h2>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import type { Kandang } from '../../types/api.ts';
|
||||
|
||||
type FeedInButtonGridProps = {
|
||||
kandangs: Kandang[];
|
||||
variant?: 'default' | 'kiosk';
|
||||
};
|
||||
|
||||
type ButtonItem = {
|
||||
key: string;
|
||||
kandangName: string;
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const toButtonItems = (kandangs: Kandang[]): ButtonItem[] =>
|
||||
kandangs.flatMap((kandang) =>
|
||||
(kandang.feed_in_button_urls ?? [])
|
||||
.filter((entry) => entry.url.trim())
|
||||
.map((entry, index) => ({
|
||||
key: `${kandang.id}-${index}`,
|
||||
kandangName: kandang.kandang_name,
|
||||
label: entry.label.trim(),
|
||||
url: entry.url.trim(),
|
||||
}))
|
||||
);
|
||||
|
||||
const FeedInButtonGrid: React.FC<FeedInButtonGridProps> = ({ kandangs, variant = 'default' }) => {
|
||||
const buttons = toButtonItems(kandangs);
|
||||
const isKiosk = variant === 'kiosk';
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-dashed border-gray-300 bg-white text-center ${
|
||||
isKiosk ? 'p-10' : 'p-8'
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-link-slash text-3xl text-gray-300 mb-3" />
|
||||
<p className="text-gray-600 font-medium">Belum ada URL Feed In yang dikonfigurasi.</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Atur alamat per kandang di menu Pengaturan.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid gap-4 ${
|
||||
isKiosk
|
||||
? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl mx-auto'
|
||||
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
||||
}`}
|
||||
>
|
||||
{buttons.map((button) => {
|
||||
const title = button.label || button.kandangName;
|
||||
const subtitle = button.label ? button.kandangName : button.url;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={button.key}
|
||||
href={button.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group flex flex-col items-center justify-center rounded-2xl border-2 border-red-100 bg-white text-center shadow-sm transition-all hover:border-red-500 hover:shadow-lg hover:-translate-y-0.5 ${
|
||||
isKiosk ? 'min-h-40 p-8' : 'min-h-32 p-6'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`mb-3 flex items-center justify-center rounded-full bg-red-50 text-red-600 group-hover:bg-red-600 group-hover:text-white transition-colors ${
|
||||
isKiosk ? 'h-16 w-16 text-3xl' : 'h-12 w-12 text-2xl'
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-arrow-up-right-from-square" />
|
||||
</span>
|
||||
<span className={`font-bold text-gray-800 ${isKiosk ? 'text-2xl' : 'text-lg'}`}>
|
||||
{title}
|
||||
</span>
|
||||
<span className="mt-2 text-xs text-gray-400 truncate max-w-full px-2">{subtitle}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedInButtonGrid;
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type { Kandang, Site } from '../../types/api.ts';
|
||||
import { Banner } from '../ui/Feedback.tsx';
|
||||
import FeedInButtonGrid from './FeedInButtonGrid.tsx';
|
||||
|
||||
type FeedInButtonPanelProps = {
|
||||
variant?: 'default' | 'kiosk';
|
||||
};
|
||||
|
||||
const hasConfiguredUrls = (kandang: Kandang): boolean =>
|
||||
(kandang.feed_in_button_urls ?? []).some((entry) => entry.url.trim());
|
||||
|
||||
const FeedInButtonPanel: React.FC<FeedInButtonPanelProps> = ({ variant = 'default' }) => {
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [kandangs, setKandangs] = useState<Kandang[]>([]);
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null);
|
||||
const [selectedKandangId, setSelectedKandangId] = useState<number | 'all'>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isKiosk = variant === 'kiosk';
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextSites = await api.sites.list();
|
||||
if (cancelled) return;
|
||||
setSites(nextSites);
|
||||
setSelectedSiteId((prev) => {
|
||||
if (prev && nextSites.some((site) => site.id === prev)) return prev;
|
||||
return nextSites[0]?.id ?? null;
|
||||
});
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSiteId == null) {
|
||||
setKandangs([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextKandangs = await api.kandangs.list({ site_id: selectedSiteId });
|
||||
if (cancelled) return;
|
||||
setKandangs(nextKandangs);
|
||||
setSelectedKandangId('all');
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedSiteId]);
|
||||
|
||||
const filterableKandangs = useMemo(() => kandangs.filter(hasConfiguredUrls), [kandangs]);
|
||||
|
||||
const displayedKandangs = useMemo(() => {
|
||||
if (selectedKandangId === 'all') return filterableKandangs;
|
||||
return filterableKandangs.filter((kandang) => kandang.id === selectedKandangId);
|
||||
}, [filterableKandangs, selectedKandangId]);
|
||||
|
||||
const chipClass = (active: boolean) =>
|
||||
`px-4 py-2 rounded-full text-sm font-semibold border transition-colors ${
|
||||
active
|
||||
? 'bg-red-600 text-white border-red-600 shadow-sm'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:border-red-300 hover:text-red-600'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={isKiosk ? 'max-w-5xl mx-auto space-y-6' : 'space-y-6'}>
|
||||
{error && <Banner kind="error">{error}</Banner>}
|
||||
|
||||
{(sites.length > 1 || filterableKandangs.length > 0) && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 space-y-4">
|
||||
{sites.length > 1 && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700 shrink-0" htmlFor="feed-in-site">
|
||||
Site
|
||||
</label>
|
||||
<select
|
||||
id="feed-in-site"
|
||||
value={selectedSiteId ?? ''}
|
||||
onChange={(e) => setSelectedSiteId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="border border-gray-300 rounded-md px-3 py-2 text-sm w-full sm:max-w-xs focus:ring-red-500 focus:border-red-500"
|
||||
>
|
||||
{sites.map((site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{site.site_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filterableKandangs.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Kandang</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={chipClass(selectedKandangId === 'all')}
|
||||
onClick={() => setSelectedKandangId('all')}
|
||||
>
|
||||
Semua
|
||||
</button>
|
||||
{filterableKandangs.map((kandang) => (
|
||||
<button
|
||||
key={kandang.id}
|
||||
type="button"
|
||||
className={chipClass(selectedKandangId === kandang.id)}
|
||||
onClick={() => setSelectedKandangId(kandang.id)}
|
||||
>
|
||||
{kandang.kandang_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-sm text-gray-500 py-10">Memuat kandang...</div>
|
||||
) : (
|
||||
<FeedInButtonGrid kandangs={displayedKandangs} variant={variant} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedInButtonPanel;
|
||||
@@ -6,6 +6,7 @@ export interface NavItem {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
permissionId?: string;
|
||||
href?: string;
|
||||
subItems?: NavItem[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -60,26 +61,40 @@ const NavLink: React.FC<NavLinkProps> = ({ item, isActive, isCollapsed, setView,
|
||||
{!isCollapsed && isOpen && (
|
||||
<ul className="pl-8 mt-1 space-y-1">
|
||||
{item.subItems.map((subItem) => (
|
||||
<li key={subItem.view}>
|
||||
<button
|
||||
onClick={() => !subItem.disabled && setView(subItem.view)}
|
||||
disabled={subItem.disabled}
|
||||
className={`w-full flex items-center h-9 pl-3 pr-2 rounded-md transition-colors duration-200 group text-left ${
|
||||
subItem.disabled
|
||||
? 'opacity-40 cursor-not-allowed text-gray-400'
|
||||
: view === subItem.view
|
||||
? 'bg-red-600 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="w-6 text-base flex items-center justify-center -ml-1">
|
||||
{subItem.icon}
|
||||
</div>
|
||||
<span className="ml-2 text-sm font-medium whitespace-nowrap">
|
||||
{subItem.label}
|
||||
</span>
|
||||
{subItem.disabled && <i className="fa-solid fa-lock ml-auto text-xs"></i>}
|
||||
</button>
|
||||
<li key={subItem.href ?? subItem.view}>
|
||||
{subItem.href ? (
|
||||
<a
|
||||
href={subItem.href}
|
||||
className={`w-full flex items-center h-9 pl-3 pr-2 rounded-md transition-colors duration-200 group text-left text-gray-500 hover:bg-gray-100 hover:text-gray-800`}
|
||||
>
|
||||
<div className="w-6 text-base flex items-center justify-center -ml-1">
|
||||
{subItem.icon}
|
||||
</div>
|
||||
<span className="ml-2 text-sm font-medium whitespace-nowrap">
|
||||
{subItem.label}
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => !subItem.disabled && setView(subItem.view)}
|
||||
disabled={subItem.disabled}
|
||||
className={`w-full flex items-center h-9 pl-3 pr-2 rounded-md transition-colors duration-200 group text-left ${
|
||||
subItem.disabled
|
||||
? 'opacity-40 cursor-not-allowed text-gray-400'
|
||||
: view === subItem.view
|
||||
? 'bg-red-600 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="w-6 text-base flex items-center justify-center -ml-1">
|
||||
{subItem.icon}
|
||||
</div>
|
||||
<span className="ml-2 text-sm font-medium whitespace-nowrap">
|
||||
{subItem.label}
|
||||
</span>
|
||||
{subItem.disabled && <i className="fa-solid fa-lock ml-auto text-xs"></i>}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -13,6 +13,14 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location = /button {
|
||||
return 301 /button/;
|
||||
}
|
||||
|
||||
location /button/ {
|
||||
try_files $uri $uri/ /button/index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
+17
-5
@@ -55,7 +55,12 @@ function getCookie(name: string): string | null {
|
||||
function parseDetail(body: unknown): string {
|
||||
if (!body || typeof body !== 'object') return 'Request failed';
|
||||
const detail = (body as { detail?: unknown }).detail;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (typeof detail === 'string') {
|
||||
if (detail.toLowerCase().includes('csrf')) {
|
||||
return 'Permintaan ditolak (CSRF). Muat ulang halaman lalu coba lagi.';
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
if (Array.isArray(detail)) return detail.map(String).join(', ');
|
||||
const entries = Object.entries(body as Record<string, unknown>)
|
||||
.filter(([key]) => key !== 'detail')
|
||||
@@ -64,8 +69,6 @@ function parseDetail(body: unknown): string {
|
||||
}
|
||||
|
||||
async function ensureCsrf(): Promise<string> {
|
||||
const existing = getCookie('csrftoken');
|
||||
if (existing) return existing;
|
||||
const res = await fetch(`${API_BASE}/auth/csrf/`, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
throw new ApiError('Gagal mengambil CSRF token', res.status, null);
|
||||
@@ -74,6 +77,12 @@ async function ensureCsrf(): Promise<string> {
|
||||
return data.csrfToken || getCookie('csrftoken') || '';
|
||||
}
|
||||
|
||||
function isCsrfFailure(status: number, body: unknown): boolean {
|
||||
if (status !== 403) return false;
|
||||
const detail = parseDetail(body).toLowerCase();
|
||||
return detail.includes('csrf');
|
||||
}
|
||||
|
||||
type Query = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
function withQuery(path: string, query?: Query): string {
|
||||
@@ -87,7 +96,7 @@ function withQuery(path: string, query?: Query): string {
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
async function request<T>(path: string, init: RequestInit = {}, csrfRetried = false): Promise<T> {
|
||||
const method = (init.method || 'GET').toUpperCase();
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Accept', 'application/json');
|
||||
@@ -121,7 +130,10 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (isCsrfFailure(res.status, body) && !csrfRetried && method !== 'GET' && method !== 'HEAD') {
|
||||
return request<T>(path, init, true);
|
||||
}
|
||||
if (res.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent(AUTH_UNAUTHORIZED_EVENT));
|
||||
}
|
||||
throw new ApiError(parseDetail(body), res.status, body);
|
||||
|
||||
@@ -23,9 +23,15 @@ export type Site = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type FeedInButtonUrl = {
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type Kandang = {
|
||||
id: number;
|
||||
kandang_name: string;
|
||||
feed_in_button_urls: FeedInButtonUrl[];
|
||||
site: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -219,6 +225,7 @@ export type SiteWrite = { site_name: string; user: number };
|
||||
export type KandangWrite = {
|
||||
kandang_name: string;
|
||||
site: number;
|
||||
feed_in_button_urls?: FeedInButtonUrl[];
|
||||
};
|
||||
export type FlockWrite = {
|
||||
flock_name: string;
|
||||
|
||||
+34
-1
@@ -1,15 +1,48 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import path from 'path';
|
||||
import type { Plugin } from 'vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const buttonRoutePlugin = (): Plugin => ({
|
||||
name: 'button-route',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const url = req.url ?? '';
|
||||
const [pathname, search = ''] = url.split('?');
|
||||
const query = search ? `?${search}` : '';
|
||||
|
||||
if (pathname === '/button') {
|
||||
res.statusCode = 301;
|
||||
res.setHeader('Location', `/button/${query}`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/button/' || pathname === '/button/index.html') {
|
||||
req.url = `/button/index.html${query}`;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), buttonRoutePlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: path.resolve(__dirname, 'index.html'),
|
||||
button: path.resolve(__dirname, 'button/index.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
host: '0.0.0.0',
|
||||
|
||||
Reference in new issue
Block a user