Files
dashboard-cpsp/components/SettingsPage.tsx
T

535 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, FeedInButtonUrl } from '../types/api.ts';
import { Banner, PageHeader } from './ui/Feedback.tsx';
import { addDaysIso, formatDateId, inclusiveDayCount, resolveCycleStatus, todayIso } from '../utils/format.ts';
const inputClass =
'border border-gray-300 rounded-md px-3 py-2 text-sm w-full focus:ring-red-500 focus:border-red-500';
const defaultCycleForm = () => ({
startDate: todayIso(),
endDate: addDaysIso(todayIso(), 34),
docInCount: 20000,
chickInWeight: 35,
});
const cycleToForm = (cycle: Cycle) => ({
startDate: cycle.start_date,
endDate: cycle.end_date,
docInCount: cycle.doc_in_count,
chickInWeight: cycle.chick_in_weight,
});
const emptyFeedInButtonUrl = (): FeedInButtonUrl => ({ label: '', url: '' });
const SettingsPage: React.FC = () => {
const { user } = useAuth();
const {
sites,
kandangs,
flocks,
cycles,
selectedSiteId,
selectedKandangId,
reload,
setSelectedSiteId,
setSelectedKandangId,
setSelectedCycleId,
} = useFarm();
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
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);
const [endDate, setEndDate] = useState(defaultCycleForm().endDate);
const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount);
const [chickInWeight, setChickInWeight] = useState(defaultCycleForm().chickInWeight);
const resetCycleForm = () => {
const defaults = defaultCycleForm();
setEditingCycleId(null);
setStartDate(defaults.startDate);
setEndDate(defaults.endDate);
setDocInCount(defaults.docInCount);
setChickInWeight(defaults.chickInWeight);
};
const beginCycleEdit = (cycle: Cycle) => {
const form = cycleToForm(cycle);
setEditingCycleId(cycle.id);
setStartDate(form.startDate);
setEndDate(form.endDate);
setDocInCount(form.docInCount);
setChickInWeight(form.chickInWeight);
setSelectedCycleId(cycle.id);
};
const buildCyclePayload = () => ({
kandang: selectedKandangId!,
total_days: inclusiveDayCount(startDate, endDate),
start_date: startDate,
end_date: endDate,
chick_in_weight: chickInWeight,
doc_in_count: docInCount,
});
const flash = (msg: string) => {
setNotice(msg);
setError(null);
};
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);
try {
await fn();
await reload();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
return (
<div>
<PageHeader
title="Pengaturan"
subtitle="Site, kandang, flock (IoT), dan siklus produksi"
/>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{notice && (
<div className="mb-4">
<Banner kind="success">{notice}</Banner>
</div>
)}
<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 ? (
<form
className="flex gap-2 mb-4"
onSubmit={(e) => {
e.preventDefault();
if (!user) return;
void wrap(async () => {
const created = await api.sites.create({ site_name: siteName, user: user.id });
setSiteName('');
setSelectedSiteId(created.id);
flash(`Site ${created.site_name} dibuat.`);
});
}}
>
<input
className={inputClass}
placeholder="Nama site"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
required
/>
<button
className="bg-red-600 text-white px-3 rounded-lg text-sm font-semibold"
disabled={saving}
>
Tambah
</button>
</form>
) : (
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat site.</p>
)}
<ul className="divide-y">
{sites.map((site) => (
<li key={site.id} className="py-2 flex items-center justify-between gap-2">
<button
className={`text-left text-sm ${site.id === selectedSiteId ? 'font-semibold text-red-600' : 'text-gray-700'}`}
onClick={() => setSelectedSiteId(site.id)}
>
{site.site_name}
</button>
{isSuperAdmin && (
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
await api.sites.remove(site.id);
flash('Site 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">Kandang</h2>
{isSuperAdmin ? (
<form
className="space-y-2 mb-4"
onSubmit={(e) => {
e.preventDefault();
if (!selectedSiteId) return;
void wrap(async () => {
const created = await api.kandangs.create({
kandang_name: kandangName,
site: selectedSiteId,
});
setKandangName('');
setSelectedKandangId(created.id);
flash(`Kandang ${created.kandang_name} dibuat.`);
});
}}
>
<input
className={inputClass}
placeholder="Nama kandang"
value={kandangName}
onChange={(e) => setKandangName(e.target.value)}
required
/>
<button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving}
>
Tambah kandang
</button>
</form>
) : (
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat kandang.</p>
)}
<ul className="divide-y">
{kandangs.map((kandang) => (
<li key={kandang.id} className="py-2 flex items-center justify-between gap-2">
<button
className={`text-left text-sm ${kandang.id === selectedKandangId ? 'font-semibold text-red-600' : 'text-gray-700'}`}
onClick={() => setSelectedKandangId(kandang.id)}
>
{kandang.kandang_name}
</button>
{isSuperAdmin && (
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
await api.kandangs.remove(kandang.id);
flash('Kandang dihapus.');
})
}
>
Hapus
</button>
)}
</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>
<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
className="space-y-2 mb-4"
onSubmit={(e) => {
e.preventDefault();
if (!selectedKandangId) return;
void wrap(async () => {
const payload = buildCyclePayload();
if (editingCycleId) {
await api.cycles.update(editingCycleId, payload);
flash('Siklus diperbarui.');
resetCycleForm();
} else {
await api.cycles.create({ ...payload, current_day: 1 });
flash('Siklus dibuat.');
resetCycleForm();
}
});
}}
>
{editingCycleId && (
<p className="text-xs font-medium text-blue-700 bg-blue-50 border border-blue-100 rounded-md px-3 py-2">
Mengedit siklus {formatDateId(startDate)} – {formatDateId(endDate)}
</p>
)}
<label className="block text-sm">
Mulai
<input
type="date"
className={`${inputClass} mt-1`}
value={startDate}
onChange={(e) => {
const nextStart = e.target.value;
setStartDate(nextStart);
if (endDate < nextStart) setEndDate(nextStart);
}}
/>
</label>
<label className="block text-sm">
Selesai
<input
type="date"
className={`${inputClass} mt-1`}
min={startDate}
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</label>
<label className="block text-sm">
DOC in (ekor)
<input
type="number"
className={`${inputClass} mt-1`}
value={docInCount}
onChange={(e) => setDocInCount(Number(e.target.value))}
/>
</label>
<label className="block text-sm">
Berat DOC (g)
<input
type="number"
className={`${inputClass} mt-1`}
min={35}
max={51}
value={chickInWeight}
onChange={(e) => {
const value = Number(e.target.value);
if (Number.isNaN(value)) return;
setChickInWeight(Math.min(51, Math.max(35, value)));
}}
/>
</label>
<div className="flex gap-2">
<button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving}
>
{editingCycleId ? 'Simpan perubahan' : 'Tambah siklus'}
</button>
{editingCycleId && (
<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"
disabled={saving}
onClick={resetCycleForm}
>
Batal
</button>
)}
</div>
</form>
<ul className="divide-y">
{cycles.map((cycle) => (
<li key={cycle.id} className="py-2 text-sm text-gray-700">
<div className="flex justify-between gap-2">
<button className="text-left" onClick={() => setSelectedCycleId(cycle.id)}>
{formatDateId(cycle.start_date)} – {formatDateId(cycle.end_date)}
<span className="block text-xs text-gray-400">
{resolveCycleStatus(cycle) === 'active' ? 'Aktif' : 'Selesai'} ·{' '}
{cycle.doc_in_count} ekor · {cycle.chick_in_weight} g
</span>
</button>
<div className="flex flex-col items-end gap-1">
<button
className="text-xs text-blue-600 hover:underline"
onClick={() => beginCycleEdit(cycle)}
>
Ubah
</button>
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
if (editingCycleId === cycle.id) resetCycleForm();
await api.cycles.remove(cycle.id);
flash('Siklus dihapus.');
})
}
>
Hapus
</button>
</div>
</div>
</li>
))}
</ul>
</section>
</div>
</div>
);
};
export default SettingsPage;