754 lines
29 KiB
TypeScript
754 lines
29 KiB
TypeScript
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;
|