update link registration site to executive dashboard
This commit is contained in:
1 parent
1e6f6b3b57
commit
d0d1ae7d24
26 files changed
+1044
-55
No files matched your search
@@ -56,7 +56,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<i className="fa-solid fa-map-marker-alt text-gray-500" />
|
||||
<select
|
||||
value={selectedSiteId ?? ''}
|
||||
onChange={(e) => setSelectedSiteId(e.target.value ? Number(e.target.value) : null)}
|
||||
onChange={(e) => setSelectedSiteId(e.target.value ? e.target.value : null)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white text-sm text-gray-900"
|
||||
>
|
||||
{sites.length === 0 && <option value="">Tidak ada site</option>}
|
||||
|
||||
+180
-3
@@ -1,14 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
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 } from '../types/api.ts';
|
||||
import { Banner, PageHeader } from './ui/Feedback.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,
|
||||
@@ -49,6 +62,9 @@ const SettingsPage: React.FC = () => {
|
||||
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();
|
||||
@@ -82,6 +98,29 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
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);
|
||||
@@ -103,10 +142,12 @@ const SettingsPage: React.FC = () => {
|
||||
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);
|
||||
@@ -189,6 +230,90 @@ const SettingsPage: React.FC = () => {
|
||||
))}
|
||||
</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 ? (
|
||||
@@ -569,6 +694,58 @@ const SettingsPage: React.FC = () => {
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,11 +10,11 @@ const { createKandangMock, createFlockMock, createCycleMock, updateCycleMock, re
|
||||
updateCycleMock: vi.fn(),
|
||||
reloadMock: vi.fn().mockResolvedValue(undefined),
|
||||
farmState: {
|
||||
sites: [{ id: 7, site_name: 'Site A', user: 99, created_at: '', updated_at: '' }],
|
||||
sites: [{ id: 'site-a', site_name: 'Site A', user: 99, created_at: '', updated_at: '' }],
|
||||
kandangs: [] as Array<{
|
||||
id: number;
|
||||
kandang_name: string;
|
||||
site: number;
|
||||
site: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>,
|
||||
@@ -26,7 +26,7 @@ const { createKandangMock, createFlockMock, createCycleMock, updateCycleMock, re
|
||||
updated_at: string;
|
||||
}>,
|
||||
cycles: [] as unknown[],
|
||||
selectedSiteId: 7,
|
||||
selectedSiteId: 'site-a',
|
||||
selectedKandangId: null as number | null,
|
||||
selectedCycleId: null as number | null,
|
||||
selectedCycle: null as unknown,
|
||||
@@ -97,14 +97,14 @@ describe('SettingsPage settings forms', () => {
|
||||
farmState.kandangs = [];
|
||||
farmState.flocks = [];
|
||||
farmState.cycles = [];
|
||||
farmState.selectedSiteId = 7;
|
||||
farmState.selectedSiteId = 'site-a';
|
||||
farmState.selectedKandangId = null;
|
||||
farmState.selectedCycleId = null;
|
||||
farmState.selectedCycle = null;
|
||||
createKandangMock.mockResolvedValue({
|
||||
id: 11,
|
||||
kandang_name: 'Kandang 1',
|
||||
site: 7,
|
||||
site: 'site-a',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
});
|
||||
@@ -155,13 +155,13 @@ describe('SettingsPage settings forms', () => {
|
||||
await waitFor(() => expect(createKandangMock).toHaveBeenCalledTimes(1));
|
||||
expect(createKandangMock).toHaveBeenCalledWith({
|
||||
kandang_name: 'Kandang 1',
|
||||
site: 7,
|
||||
site: 'site-a',
|
||||
});
|
||||
});
|
||||
|
||||
it('submits flock fields for the selected kandang', async () => {
|
||||
farmState.kandangs = [
|
||||
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
|
||||
{ id: 11, kandang_name: 'Kandang 1', site: 'site-a', created_at: '', updated_at: '' },
|
||||
];
|
||||
farmState.selectedKandangId = 11;
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('SettingsPage settings forms', () => {
|
||||
|
||||
it('submits cycle fields without flock assignment', async () => {
|
||||
farmState.kandangs = [
|
||||
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
|
||||
{ id: 11, kandang_name: 'Kandang 1', site: 'site-a', created_at: '', updated_at: '' },
|
||||
];
|
||||
farmState.selectedKandangId = 11;
|
||||
|
||||
@@ -207,7 +207,7 @@ describe('SettingsPage settings forms', () => {
|
||||
|
||||
it('updates an existing cycle from the edit form', async () => {
|
||||
farmState.kandangs = [
|
||||
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
|
||||
{ id: 11, kandang_name: 'Kandang 1', site: 'site-a', created_at: '', updated_at: '' },
|
||||
];
|
||||
farmState.selectedKandangId = 11;
|
||||
farmState.cycles = [
|
||||
|
||||
@@ -14,7 +14,7 @@ const hasConfiguredUrls = (kandang: Kandang): boolean =>
|
||||
const FeedInButtonPanel: React.FC<FeedInButtonPanelProps> = ({ variant = 'default' }) => {
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [kandangs, setKandangs] = useState<Kandang[]>([]);
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null);
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
|
||||
const [selectedKandangId, setSelectedKandangId] = useState<number | 'all'>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -98,7 +98,7 @@ const FeedInButtonPanel: React.FC<FeedInButtonPanelProps> = ({ variant = 'defaul
|
||||
<select
|
||||
id="feed-in-site"
|
||||
value={selectedSiteId ?? ''}
|
||||
onChange={(e) => setSelectedSiteId(e.target.value ? Number(e.target.value) : null)}
|
||||
onChange={(e) => setSelectedSiteId(e.target.value ? e.target.value : null)}
|
||||
className="border border-gray-300 rounded-md px-3 py-2 text-sm w-full sm:max-w-xs focus:ring-red-500 focus:border-red-500"
|
||||
>
|
||||
{sites.map((site) => (
|
||||
|
||||
@@ -53,3 +53,74 @@ export const EmptyFarm: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
.
|
||||
</Banner>
|
||||
);
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Lanjutkan',
|
||||
cancelLabel = 'Batal',
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
onClick={() => {
|
||||
if (!busy) onCancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl bg-white p-5 shadow-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-600 text-white">
|
||||
<i className="fa-solid fa-triangle-exclamation" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<h3 id="confirm-dialog-title" className="text-lg font-semibold text-gray-800">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-1 text-sm text-gray-600">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{busy ? 'Memproses...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user