update api export for executive and fixing closing cycle flow
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user