577 lines
22 KiB
TypeScript
577 lines
22 KiB
TypeScript
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 { 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';
|
||
|
||
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 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 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);
|
||
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();
|
||
}
|
||
});
|
||
}}
|
||
>
|
||
{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>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SettingsPage;
|