update api export for executive and fixing closing cycle flow

This commit is contained in:
Alberto-Audrix committed 2026-09-15 16:53:25 +07:00
1 parent d0d1ae7d24
commit 07d36081ce
23 files changed
+1378 -101

No files matched your search

@@ -0,0 +1,159 @@
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 { AlertDialog, Banner, ConfirmDialog } from '../ui/Feedback.tsx';
import { formatDateId, resolveCycleStatus, todayIso } from '../../utils/format.ts';
type ConfirmKind = 'request_close' | 'cancel_request';
const DashboardCloseCycleControls: React.FC = () => {
const { user } = useAuth();
const { selectedCycle, reload } = useFarm();
const [proposedCloseDate, setProposedCloseDate] = useState(todayIso());
const [confirmKind, setConfirmKind] = useState<ConfirmKind | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const canRequestClose = user?.status === 'active';
const status = selectedCycle ? resolveCycleStatus(selectedCycle) : null;
const showControls = Boolean(canRequestClose && selectedCycle && status !== 'closed');
const run = async (action: () => Promise<void>, successMessage: string) => {
if (!selectedCycle) return;
setBusy(true);
setError(null);
try {
await action();
await reload();
setNotice(successMessage);
setConfirmKind(null);
} catch (err) {
setConfirmKind(null);
setError(errorMessage(err));
void reload();
} finally {
setBusy(false);
}
};
if (!showControls && !error) return null;
return (
<div className="flex flex-col items-end gap-1">
{showControls && notice && <Banner kind="success">{notice}</Banner>}
{showControls && status === 'active' && selectedCycle && (
<div className="flex flex-wrap items-center justify-end gap-2">
<label className="flex items-center gap-1 text-xs text-gray-600">
<span className="sr-only">Tanggal usulan tutup</span>
<input
type="date"
className="border border-gray-300 rounded-lg px-2 py-2 text-sm"
min={selectedCycle.start_date}
value={proposedCloseDate}
onChange={(e) => setProposedCloseDate(e.target.value)}
disabled={busy}
/>
</label>
<button
type="button"
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700 disabled:opacity-50"
disabled={busy || !proposedCloseDate}
onClick={() => {
setNotice(null);
setError(null);
setConfirmKind('request_close');
}}
>
Ajukan tutup
</button>
</div>
)}
{showControls && status === 'pending_close' && selectedCycle && (
<div className="flex flex-col items-end gap-1">
{selectedCycle.proposed_end_date && (
<p className="text-xs text-amber-700">
Menunggu GM · usulan {formatDateId(selectedCycle.proposed_end_date)}
</p>
)}
<button
type="button"
className="rounded-lg border border-amber-600 px-4 py-2 text-sm font-semibold text-amber-800 hover:bg-amber-50 disabled:opacity-50"
disabled={busy}
onClick={() => {
setNotice(null);
setError(null);
setConfirmKind('cancel_request');
}}
>
Batalkan permintaan
</button>
</div>
)}
<ConfirmDialog
open={confirmKind === 'request_close'}
title="Ajukan tutup siklus?"
message={
<p>
Permintaan tutup akan dikirim ke pusat dengan tanggal usulan{' '}
<strong>{formatDateId(proposedCloseDate)}</strong>. GM harus menyetujui sebelum
siklus ditutup. Anda masih bisa membatalkan selama status menunggu GM.
</p>
}
confirmLabel="Ya, ajukan tutup"
busy={busy}
onCancel={() => {
if (!busy) setConfirmKind(null);
}}
onConfirm={() => {
if (busy || !selectedCycle) return;
void run(
() =>
api.cycles
.requestClose(selectedCycle.id, {
proposed_end_date: proposedCloseDate,
})
.then(() => undefined),
'Permintaan tutup dikirim ke pusat.'
);
}}
/>
<ConfirmDialog
open={confirmKind === 'cancel_request'}
title="Batalkan permintaan tutup?"
message={
<p>
Permintaan tutup ke pusat akan dibatalkan. Siklus tetap aktif dan dapat diajukan
tutup lagi nanti.
</p>
}
confirmLabel="Ya, batalkan"
busy={busy}
onCancel={() => {
if (!busy) setConfirmKind(null);
}}
onConfirm={() => {
if (busy || !selectedCycle) return;
void run(
() => api.cycles.cancelCloseRequest(selectedCycle.id).then(() => undefined),
'Permintaan tutup dibatalkan.'
);
}}
/>
<AlertDialog
open={error != null}
title="Gagal mengajukan tutup"
kind="error"
message={<p className="whitespace-pre-wrap break-words">{error}</p>}
dismissLabel="Tutup"
onDismiss={() => setError(null)}
/>
</div>
);
};
export default DashboardCloseCycleControls;
@@ -0,0 +1,119 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DashboardCloseCycleControls from '../DashboardCloseCycleControls.tsx';
const {
requestCloseMock,
cancelCloseRequestMock,
reloadMock,
farmState,
authState,
} = vi.hoisted(() => ({
requestCloseMock: vi.fn().mockResolvedValue({}),
cancelCloseRequestMock: vi.fn().mockResolvedValue({}),
reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: {
selectedCycle: {
id: 5,
start_date: '2026-09-01',
end_date: null,
status: 'active',
proposed_end_date: null,
visible_through_date: '2026-09-10',
} as Record<string, unknown> | null,
},
authState: {
user: {
id: 1,
user_name: 'staff',
display_name: 'Staff',
status: 'active',
},
},
}));
vi.mock('../../../services/apiClient.ts', () => ({
api: {
cycles: {
requestClose: requestCloseMock,
cancelCloseRequest: cancelCloseRequestMock,
},
},
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
}));
vi.mock('../../../context/AuthContext.tsx', () => ({
useAuth: () => authState,
}));
vi.mock('../../../context/FarmContext.tsx', () => ({
useFarm: () => ({
selectedCycle: farmState.selectedCycle,
reload: reloadMock,
}),
}));
describe('DashboardCloseCycleControls', () => {
beforeEach(() => {
requestCloseMock.mockClear();
cancelCloseRequestMock.mockClear();
reloadMock.mockClear();
authState.user.status = 'active';
farmState.selectedCycle = {
id: 5,
start_date: '2026-09-01',
end_date: null,
status: 'active',
proposed_end_date: null,
visible_through_date: '2026-09-10',
};
});
it('shows date + Ajukan tutup for active cycle and requires confirm before API', async () => {
render(<DashboardCloseCycleControls />);
expect(screen.getByRole('button', { name: /Ajukan tutup/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Ajukan tutup/i }));
expect(requestCloseMock).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Ya, ajukan tutup/i }));
await waitFor(() => {
expect(requestCloseMock).toHaveBeenCalledWith(5, {
proposed_end_date: expect.any(String),
});
});
expect(reloadMock).toHaveBeenCalled();
});
it('shows cancel control when pending_close', () => {
farmState.selectedCycle = {
id: 5,
start_date: '2026-09-01',
end_date: null,
status: 'pending_close',
proposed_end_date: '2026-09-15',
visible_through_date: '2026-09-10',
};
render(<DashboardCloseCycleControls />);
expect(screen.queryByRole('button', { name: /Ajukan tutup/i })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Batalkan permintaan/i })).toBeInTheDocument();
});
it('hides controls for non-active users', () => {
authState.user.status = 'superadmin';
render(<DashboardCloseCycleControls />);
expect(screen.queryByRole('button', { name: /Ajukan tutup/i })).not.toBeInTheDocument();
});
it('shows API failure in an alert popup', async () => {
requestCloseMock.mockRejectedValueOnce(
new Error('gagal kirim ke pusat — coba lagi (400: {"detail":"Cycle is already closed."})')
);
render(<DashboardCloseCycleControls />);
fireEvent.click(screen.getByRole('button', { name: /Ajukan tutup/i }));
fireEvent.click(screen.getByRole('button', { name: /Ya, ajukan tutup/i }));
expect(await screen.findByRole('alertdialog')).toBeInTheDocument();
expect(screen.getByText(/Cycle is already closed/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^Tutup$/i }));
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
});