update link registration site to executive dashboard

This commit is contained in:
Alberto-Audrix committed 2026-09-15 09:44:45 +07:00
1 parent 1e6f6b3b57
commit d0d1ae7d24
26 files changed
+1044 -55

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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>
);
};
+9 -9
View File
@@ -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) => (
+71
View File
@@ -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>
);
};