107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import { api, errorMessage } from '../services/apiClient.ts';
|
|
import { useAuth } from '../context/AuthContext.tsx';
|
|
import { useFarm } from '../context/FarmContext.tsx';
|
|
import { Banner } from './ui/Feedback.tsx';
|
|
import { formatDateId, resolveCycleStatus } from '../utils/format.ts';
|
|
|
|
/** GM queue: approve/reject pending cycle closes from city sites. */
|
|
const PendingCloseQueue: React.FC = () => {
|
|
const { user } = useAuth();
|
|
const { cycles, sites, kandangs, reload } = useFarm();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
const [busyId, setBusyId] = useState<number | null>(null);
|
|
|
|
const isGm = user?.status === 'gm';
|
|
const pending = useMemo(
|
|
() => cycles.filter((c) => resolveCycleStatus(c) === 'pending_close'),
|
|
[cycles]
|
|
);
|
|
|
|
if (pending.length === 0) return null;
|
|
|
|
const siteName = (kandangId: number) => {
|
|
const kandang = kandangs.find((k) => k.id === kandangId);
|
|
const site = sites.find((s) => s.id === kandang?.site);
|
|
return site?.site_name ?? '—';
|
|
};
|
|
|
|
const act = async (id: number, decision: 'approve' | 'reject') => {
|
|
setBusyId(id);
|
|
setError(null);
|
|
try {
|
|
const result =
|
|
decision === 'approve'
|
|
? await api.cycles.approveClose(id)
|
|
: await api.cycles.rejectClose(id);
|
|
const siteError =
|
|
result && typeof result === 'object' && 'site_error' in result
|
|
? (result as { site_error?: string }).site_error
|
|
: undefined;
|
|
setNotice(
|
|
siteError
|
|
? `Keputusan tersimpan di pusat. ${siteError}`
|
|
: decision === 'approve'
|
|
? 'Siklus ditutup.'
|
|
: 'Permintaan ditolak.'
|
|
);
|
|
await reload();
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="bg-white rounded-xl shadow-sm p-5 mb-6">
|
|
<h2 className="font-semibold text-gray-800 mb-1">Permintaan tutup siklus</h2>
|
|
<p className="text-xs text-gray-500 mb-3">Menunggu persetujuan GM</p>
|
|
{error && <Banner kind="error">{error}</Banner>}
|
|
{notice && <Banner kind="success">{notice}</Banner>}
|
|
<ul className="divide-y">
|
|
{pending.map((cycle) => (
|
|
<li key={cycle.id} className="py-3 flex flex-wrap items-center justify-between gap-3">
|
|
<div className="text-sm text-gray-800">
|
|
<p className="font-medium">
|
|
{siteName(cycle.kandang)} · {cycle.kandang_name}
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
Mulai {formatDateId(cycle.start_date)}
|
|
{cycle.proposed_end_date
|
|
? ` · usulan tutup ${formatDateId(cycle.proposed_end_date)}`
|
|
: ''}
|
|
</p>
|
|
</div>
|
|
{isGm ? (
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={busyId === cycle.id}
|
|
className="bg-red-600 text-white px-3 py-1.5 rounded-lg text-xs font-semibold disabled:opacity-50"
|
|
onClick={() => void act(cycle.id, 'approve')}
|
|
>
|
|
Setujui
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busyId === cycle.id}
|
|
className="border border-gray-300 text-gray-700 px-3 py-1.5 rounded-lg text-xs font-semibold hover:bg-gray-50 disabled:opacity-50"
|
|
onClick={() => void act(cycle.id, 'reject')}
|
|
>
|
|
Tolak
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-gray-400">Hanya GM yang dapat menyetujui</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default PendingCloseQueue;
|