Files
dashboard-cpsp/components/SettingsPage.tsx
T

754 lines
29 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, { useEffect, 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, PusatRegistration } from '../types/api.ts';
import { Banner, ConfirmDialog, PageHeader } from './ui/Feedback.tsx';
import { formatDateId, 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';
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
type PusatConfirm =
| { kind: 'register'; code: string; siteName: string }
| { kind: 'change-code'; code: string; previousCode: string };
const defaultCycleForm = () => ({
startDate: todayIso(),
docInCount: 20000,
chickInWeight: 35,
});
const cycleToForm = (cycle: Cycle) => ({
startDate: cycle.start_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 [proposedCloseDate, setProposedCloseDate] = useState(todayIso());
const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount);
const [chickInWeight, setChickInWeight] = useState(defaultCycleForm().chickInWeight);
const [registerCode, setRegisterCode] = useState('');
const [pusatRegistration, setPusatRegistration] = useState<PusatRegistration | null>(null);
const [pusatConfirm, setPusatConfirm] = useState<PusatConfirm | null>(null);
const resetCycleForm = () => {
const defaults = defaultCycleForm();
setEditingCycleId(null);
setStartDate(defaults.startDate);
setDocInCount(defaults.docInCount);
setChickInWeight(defaults.chickInWeight);
};
const beginCycleEdit = (cycle: Cycle) => {
const form = cycleToForm(cycle);
setEditingCycleId(cycle.id);
setStartDate(form.startDate);
setDocInCount(form.docInCount);
setChickInWeight(form.chickInWeight);
setSelectedCycleId(cycle.id);
};
const buildCyclePayload = () => ({
kandang: selectedKandangId!,
total_days: 1,
start_date: startDate,
chick_in_weight: chickInWeight,
doc_in_count: docInCount,
});
const flash = (msg: string) => {
setNotice(msg);
setError(null);
};
const isSuperAdmin = user?.status === 'superadmin';
const canRequestClose = user?.status === 'active';
const selectedSite = sites.find((s) => s.id === selectedSiteId) ?? null;
useEffect(() => {
if (!canRequestClose || selectedSiteId == null) {
setPusatRegistration(null);
return;
}
let cancelled = false;
void (async () => {
try {
const rows = await api.pusat.registration({ site_id: selectedSiteId });
if (!cancelled) {
setPusatRegistration(rows[0] ?? null);
if (rows[0]?.code) setRegisterCode(rows[0].code);
}
} catch {
if (!cancelled) setPusatRegistration(null);
}
})();
return () => {
cancelled = true;
};
}, [canRequestClose, selectedSiteId]);
const statusLabel = (cycle: Cycle) => {
const status = resolveCycleStatus(cycle);
if (status === 'pending_close') return 'Menunggu persetujuan GM';
if (status === 'closed') return 'Selesai';
return 'Aktif';
};
const selectedKandang = kandangs.find((k) => k.id === selectedKandangId) ?? null;
const editingCycle = editingCycleId
? (cycles.find((c) => c.id === editingCycleId) ?? null)
: 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);
setNotice(null);
try {
await fn();
await reload();
} catch (err) {
setNotice(null);
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>
{canRequestClose && (
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-1">Daftar ke pusat</h2>
<p className="text-xs text-gray-500 mb-4">
Pilih site di daftar kiri, lalu masukkan kode. GM di dasbor pusat akan memakai kode
yang sama untuk menambahkan site ke daftar tampilan mereka.
</p>
{!selectedSite ? (
<p className="text-sm text-gray-500">Pilih site terlebih dahulu.</p>
) : (
<div className="space-y-3">
<p className="text-sm text-gray-700">
Site: <strong>{selectedSite.site_name}</strong>
</p>
{pusatRegistration && (
<p className="text-xs text-green-700 bg-green-50 border border-green-200 rounded-lg px-3 py-2">
Sudah terdaftar — kode <strong>{pusatRegistration.code}</strong>
{pusatRegistration.registered_at
? ` · ${formatDateId(pusatRegistration.registered_at.slice(0, 10))}`
: ''}
</p>
)}
<label className="block text-sm text-gray-700">
Kode
<input
className={`${inputClass} mt-1`}
placeholder="sukawarna"
value={registerCode}
onChange={(e) => setRegisterCode(slugify(e.target.value))}
required
/>
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50"
disabled={saving || !registerCode || !!pusatRegistration}
onClick={() => {
const code = slugify(registerCode);
if (!code || !selectedSite || pusatRegistration) return;
setPusatConfirm({
kind: 'register',
code,
siteName: selectedSite.site_name,
});
}}
>
{saving && !pusatRegistration ? 'Mendaftarkan...' : 'Daftar ke pusat'}
</button>
{pusatRegistration && (
<button
type="button"
className="border border-red-600 text-red-600 px-4 py-2 rounded-lg text-sm font-semibold hover:bg-red-50 disabled:opacity-50"
disabled={
saving ||
!registerCode ||
slugify(registerCode) === pusatRegistration.code
}
onClick={() => {
const code = slugify(registerCode);
if (!code || code === pusatRegistration.code) return;
setPusatConfirm({
kind: 'change-code',
code,
previousCode: pusatRegistration.code,
});
}}
>
{saving ? 'Mengubah...' : 'Ubah kode'}
</button>
)}
</div>
{pusatRegistration && (
<p className="text-xs text-gray-500">
Daftar ke pusat terkunci karena site sudah terdaftar. Gunakan Ubah kode untuk
mengganti kode.
</p>
)}
</div>
)}
</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();
}
});
}}
>
{editingCycle && (
<p className="text-xs font-medium text-blue-700 bg-blue-50 border border-blue-100 rounded-md px-3 py-2">
Mengedit siklus mulai {formatDateId(startDate)}
{editingCycle.end_date
? ` (selesai ${formatDateId(editingCycle.end_date)}, hanya via GM)`
: ' — tanggal selesai ditetapkan setelah persetujuan GM'}
</p>
)}
<p className="text-xs text-gray-500">
Tanggal selesai tidak diisi di sini. Ajukan tutup siklus setelah produksi selesai; GM
yang menyetujui end date.
</p>
<label className="block text-sm">
Mulai
<input
type="date"
className={`${inputClass} mt-1`}
value={startDate}
onChange={(e) => setStartDate(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={1}
value={chickInWeight}
onChange={(e) => {
const value = Number(e.target.value);
if (Number.isNaN(value)) return;
setChickInWeight(Math.max(1, 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) => {
const status = resolveCycleStatus(cycle);
return (
<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)}
{cycle.end_date ? ` – ${formatDateId(cycle.end_date)}` : ''}
<span className="block text-xs text-gray-400">
{statusLabel(cycle)}
{status === 'pending_close' && cycle.proposed_end_date
? ` · usulan tutup ${formatDateId(cycle.proposed_end_date)}`
: ''}{' '}
· {cycle.doc_in_count} ekor · {cycle.chick_in_weight} g
</span>
</button>
<div className="flex flex-col items-end gap-1">
{canRequestClose && status === 'active' && (
<div className="flex items-center gap-1">
<input
type="date"
className="border border-gray-300 rounded px-1 py-0.5 text-xs"
min={cycle.start_date}
value={proposedCloseDate}
onChange={(e) => setProposedCloseDate(e.target.value)}
/>
<button
className="text-xs text-red-600 hover:underline"
onClick={() =>
void wrap(async () => {
await api.cycles.requestClose(cycle.id, {
proposed_end_date: proposedCloseDate,
});
flash('Permintaan tutup dikirim ke pusat.');
})
}
>
Ajukan tutup
</button>
</div>
)}
{canRequestClose && status === 'pending_close' && (
<button
className="text-xs text-amber-700 hover:underline"
onClick={() =>
void wrap(async () => {
await api.cycles.cancelCloseRequest(cycle.id);
flash('Permintaan tutup dibatalkan.');
})
}
>
Batalkan permintaan
</button>
)}
<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>
<ConfirmDialog
open={pusatConfirm != null}
title={
pusatConfirm?.kind === 'change-code' ? 'Ubah kode pusat?' : 'Daftar ke pusat?'
}
message={
pusatConfirm?.kind === 'change-code' ? (
<p>
Kode akan diganti dari <strong>{pusatConfirm.previousCode}</strong> menjadi{' '}
<strong>{pusatConfirm.code}</strong>. GM yang sudah memakai kode lama perlu
menyesuaikan.
</p>
) : pusatConfirm ? (
<p>
Site <strong>{pusatConfirm.siteName}</strong> akan didaftarkan ke pusat dengan kode{' '}
<strong>{pusatConfirm.code}</strong>. Setelah terdaftar, tombol Daftar terkunci.
</p>
) : null
}
confirmLabel={
pusatConfirm?.kind === 'change-code' ? 'Ya, ubah kode' : 'Ya, daftar'
}
busy={saving}
onCancel={() => {
if (!saving) setPusatConfirm(null);
}}
onConfirm={() => {
if (!pusatConfirm || !selectedSiteId || saving) return;
const pending = pusatConfirm;
void wrap(async () => {
if (pending.kind === 'register') {
const reg = await api.pusat.register({
code: pending.code,
site_id: selectedSiteId,
});
setPusatRegistration(reg);
setRegisterCode(reg.code);
flash(`Site ${reg.site_name} terdaftar ke pusat (kode ${reg.code}).`);
} else {
const reg = await api.pusat.changeCode({
code: pending.code,
site_id: selectedSiteId,
});
setPusatRegistration(reg);
setRegisterCode(reg.code);
flash(`Kode pusat diubah menjadi ${reg.code}.`);
}
setPusatConfirm(null);
});
}}
/>
</div>
);
};
export default SettingsPage;