Files
dashboard-cpsp-executive/components/SettingsPage.tsx
T

558 lines
19 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts';
import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx';
import type { ActiveSite, User, UserStatus } from '../types/api.ts';
import { Banner, PageHeader, Spinner } from './ui/Feedback.tsx';
const inputClass =
'border border-gray-300 rounded-md px-3 py-2 text-sm w-full focus:ring-red-500 focus:border-red-500';
const ROLE_LABELS: Partial<Record<UserStatus, string>> = {
gm: 'GM',
gm_admin: 'GM Admin',
buh: 'BUH',
bu_admin: 'BU Admin',
account_admin: 'Account Admin',
};
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
function isGmRole(status?: UserStatus | string | null): boolean {
return status === 'gm' || status === 'gm_admin';
}
function isBuhRole(status?: UserStatus | string | null): boolean {
return status === 'buh' || status === 'bu_admin';
}
const BuhViewList: React.FC = () => {
const { user } = useAuth();
const isPrimaryBuh = user?.status === 'buh';
const [gms, setGms] = useState<User[]>([]);
const [pending, setPending] = useState<User[]>([]);
const [activeSites, setActiveSites] = useState<ActiveSite[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const nextUsers = await api.users.list();
const nextSites = await api.activeSites.list();
const nextPending = isPrimaryBuh ? await api.users.listPendingManaged() : [];
setGms(nextUsers.filter((u) => u.status === 'gm'));
setPending(nextPending);
setActiveSites(nextSites);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, [isPrimaryBuh]);
useEffect(() => {
void reload();
}, [reload]);
const sitesByGm = useMemo(() => {
const map = new Map<number, ActiveSite[]>();
for (const site of activeSites) {
if (site.registered_by == null) continue;
const rows = map.get(site.registered_by) ?? [];
rows.push(site);
map.set(site.registered_by, rows);
}
return map;
}, [activeSites]);
const decide = async (gmId: number, decision: 'approve' | 'reject') => {
setBusyId(gmId);
setError(null);
try {
if (decision === 'approve') {
await api.users.approveManagedBy(gmId);
setNotice('GM dikonfirmasi di bawah pengelolaan Anda.');
} else {
await api.users.rejectManagedBy(gmId);
setNotice('Permintaan GM ditolak.');
}
await reload();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
return (
<div>
<PageHeader
title="Pengaturan"
subtitle={
isPrimaryBuh
? 'Konfirmasi GM di bawah Anda, lalu lihat site yang mereka daftarkan'
: 'Lihat site dari GM yang terhubung (konfirmasi hanya oleh BUH)'
}
/>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{notice && (
<div className="mb-4">
<Banner kind="success">{notice}</Banner>
</div>
)}
{isPrimaryBuh && (
<section className="bg-white rounded-xl shadow-sm p-5 mb-6">
<h2 className="font-semibold text-gray-800 mb-1">Permintaan GM</h2>
<p className="text-xs text-gray-500 mb-4">
GM memilih Anda sebagai BUH. Konfirmasi jika mereka memang di bawah pengawasan Anda.
</p>
{loading ? (
<Spinner />
) : pending.length === 0 ? (
<p className="text-sm text-gray-500">Tidak ada permintaan menunggu.</p>
) : (
<ul className="divide-y">
{pending.map((gm) => (
<li
key={gm.id}
className="py-3 flex flex-wrap items-center justify-between gap-3"
>
<div>
<p className="text-sm font-semibold text-gray-800">{gm.display_name}</p>
<p className="text-xs text-gray-500">{ROLE_LABELS[gm.status] ?? gm.status}</p>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={busyId === gm.id}
className="bg-red-600 text-white px-3 py-1.5 rounded-lg text-xs font-semibold disabled:opacity-50"
onClick={() => void decide(gm.id, 'approve')}
>
Konfirmasi
</button>
<button
type="button"
disabled={busyId === gm.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 decide(gm.id, 'reject')}
>
Tolak
</button>
</div>
</li>
))}
</ul>
)}
</section>
)}
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-1">GM yang dikelola</h2>
<p className="text-xs text-gray-500 mb-4">
Sudah dikonfirmasi (akun GM saja). Site di daftar tampilan mereka otomatis masuk rollup
Anda.
</p>
{loading ? (
<Spinner />
) : gms.length === 0 ? (
<p className="text-sm text-gray-500">Belum ada GM yang terkonfirmasi di bawah Anda.</p>
) : (
<ul className="space-y-4">
{gms.map((gm) => {
const sites = sitesByGm.get(gm.id) ?? [];
return (
<li key={gm.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
<p className="text-sm font-semibold text-gray-800">{gm.display_name}</p>
<p className="text-xs text-gray-500">
{ROLE_LABELS[gm.status] ?? gm.status} · {sites.length} site
</p>
</div>
{sites.length === 0 ? (
<p className="text-xs text-gray-500">Belum ada site di daftar tampilan GM ini.</p>
) : (
<ul className="divide-y">
{sites.map((site) => (
<li key={site.id} className="py-2">
<p className="text-sm text-gray-800">{site.name}</p>
<p className="text-xs text-gray-500">
{site.code}
{site.is_active ? '' : ' · nonaktif'}
</p>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</section>
</div>
);
};
const GmViewList: React.FC = () => {
const { user, refreshUser } = useAuth();
const { reload: reloadFarm } = useFarm();
const [activeSites, setActiveSites] = useState<ActiveSite[]>([]);
const [buhs, setBuhs] = useState<User[]>([]);
const [selectedBuhId, setSelectedBuhId] = useState<number | ''>('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [buhBusy, setBuhBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [codeTouched, setCodeTouched] = useState(false);
const [apiBaseUrl, setApiBaseUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const flash = (msg: string) => {
setNotice(msg);
setError(null);
};
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const sitesPromise = api.activeSites.list();
const mePromise = refreshUser();
const buhsPromise =
user?.status === 'gm' ? api.users.listBuhs() : Promise.resolve([] as User[]);
const [sites, me, nextBuhs] = await Promise.all([sitesPromise, mePromise, buhsPromise]);
setActiveSites(sites);
setBuhs(nextBuhs);
if (me?.managed_by_pending) {
setSelectedBuhId(me.managed_by_pending);
} else if (me?.managed_by) {
setSelectedBuhId(me.managed_by);
}
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, [refreshUser, user?.status]);
useEffect(() => {
void reload();
}, [reload]);
const resetForm = () => {
setName('');
setCode('');
setCodeTouched(false);
setApiBaseUrl('');
setApiKey('');
};
const wrap = async (fn: () => Promise<void>) => {
setSaving(true);
setError(null);
try {
await fn();
await reload();
await reloadFarm();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const submitBuhRequest = async () => {
if (selectedBuhId === '') return;
setBuhBusy(true);
setError(null);
try {
await api.users.requestManagedBy(selectedBuhId);
await refreshUser();
flash('Permintaan dikirim. Menunggu konfirmasi BUH.');
} catch (err) {
setError(errorMessage(err));
} finally {
setBuhBusy(false);
}
};
const cancelBuhRequest = async () => {
setBuhBusy(true);
setError(null);
try {
await api.users.cancelManagedByRequest();
await refreshUser();
flash('Permintaan dibatalkan.');
} catch (err) {
setError(errorMessage(err));
} finally {
setBuhBusy(false);
}
};
return (
<div>
<PageHeader
title="Pengaturan"
subtitle="Pilih BUH Anda, lalu daftarkan site ke daftar tampilan"
/>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{notice && (
<div className="mb-4">
<Banner kind="success">{notice}</Banner>
</div>
)}
{user?.status === 'gm' && (
<section className="bg-white rounded-xl shadow-sm p-5 mb-6">
<h2 className="font-semibold text-gray-800 mb-1">BUH pengelola</h2>
<p className="text-xs text-gray-500 mb-4">
Pilih BUH yang membawahi Anda. Mereka harus mengonfirmasi sebelum Anda masuk ke rollup
mereka.
</p>
{loading ? (
<Spinner />
) : (
<div className="space-y-3 max-w-xl">
{user?.managed_by_name && (
<p className="text-sm text-gray-700">
Saat ini dikonfirmasi di bawah:{' '}
<strong className="text-gray-900">{user.managed_by_name}</strong>
</p>
)}
{user?.managed_by_pending_name && (
<p className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Menunggu konfirmasi dari <strong>{user.managed_by_pending_name}</strong>
</p>
)}
<label className="block text-sm text-gray-700">
Pilih BUH
<select
className={`${inputClass} mt-1`}
value={selectedBuhId === '' ? '' : String(selectedBuhId)}
onChange={(e) =>
setSelectedBuhId(e.target.value ? Number(e.target.value) : '')
}
>
<option value="">— pilih —</option>
{buhs.map((buh) => (
<option key={buh.id} value={buh.id}>
{buh.display_name}
</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={
buhBusy ||
selectedBuhId === '' ||
(user?.managed_by === selectedBuhId && !user.managed_by_pending) ||
user?.managed_by_pending === selectedBuhId
}
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50"
onClick={() => void submitBuhRequest()}
>
{buhBusy ? 'Mengirim...' : 'Kirim permintaan'}
</button>
{user?.managed_by_pending != null && (
<button
type="button"
disabled={buhBusy}
className="border border-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-semibold hover:bg-gray-50 disabled:opacity-50"
onClick={() => void cancelBuhRequest()}
>
Batalkan permintaan
</button>
)}
</div>
</div>
)}
</section>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-1">Tambah site</h2>
<p className="text-xs text-gray-500 mb-4">
Daftarkan URL API dasbor kota agar data site muncul di daftar tampilan Anda.
</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
const resolvedCode = code || slugify(name);
if (!resolvedCode) return;
void wrap(async () => {
const created = await api.activeSites.create({
code: resolvedCode,
name: name.trim(),
api_base_url: apiBaseUrl.trim(),
...(apiKey.trim() ? { api_key: apiKey.trim() } : {}),
is_active: true,
});
resetForm();
flash(`Site ${created.name} ditambahkan ke daftar tampilan.`);
});
}}
>
<label className="block text-sm text-gray-700">
Nama site
<input
className={`${inputClass} mt-1`}
placeholder="Contoh: Sukawarna"
value={name}
onChange={(e) => {
const next = e.target.value;
setName(next);
if (!codeTouched) setCode(slugify(next));
}}
required
/>
</label>
<label className="block text-sm text-gray-700">
Kode
<input
className={`${inputClass} mt-1`}
placeholder="sukawarna"
value={code}
onChange={(e) => {
setCodeTouched(true);
setCode(slugify(e.target.value));
}}
required
/>
</label>
<label className="block text-sm text-gray-700">
URL API kota
<input
className={`${inputClass} mt-1`}
type="url"
placeholder="https://site.example/api/v1"
value={apiBaseUrl}
onChange={(e) => setApiBaseUrl(e.target.value)}
required
/>
</label>
<label className="block text-sm text-gray-700">
API key (opsional)
<input
className={`${inputClass} mt-1`}
type="password"
autoComplete="off"
placeholder="X-API-Key untuk sinkron dari kota"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</label>
<button
type="submit"
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50"
disabled={saving}
>
{saving ? 'Menyimpan...' : 'Tambah ke daftar'}
</button>
</form>
</section>
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-1">Daftar tampilan</h2>
<p className="text-xs text-gray-500 mb-4">
Site yang terdaftar untuk akun GM / GM Admin Anda.
</p>
{loading ? (
<Spinner />
) : activeSites.length === 0 ? (
<p className="text-sm text-gray-500">Belum ada site di daftar tampilan.</p>
) : (
<ul className="divide-y">
{activeSites.map((site) => (
<li key={site.id} className="py-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-800 truncate">{site.name}</p>
<p className="text-xs text-gray-500 truncate">{site.code}</p>
<p className="text-xs text-gray-400 truncate mt-0.5">{site.api_base_url}</p>
<p className="text-xs mt-1">
<span
className={
site.is_active
? 'text-green-600 font-medium'
: 'text-gray-400 font-medium'
}
>
{site.is_active ? 'Aktif' : 'Nonaktif'}
</span>
{site.has_api_key ? ' · API key tersimpan' : ''}
</p>
</div>
<button
type="button"
className="text-xs text-red-500 hover:underline shrink-0"
disabled={saving}
onClick={() =>
void wrap(async () => {
await api.activeSites.remove(site.id);
flash(`Site ${site.name} dihapus dari daftar tampilan.`);
})
}
>
Hapus
</button>
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
};
const SettingsPage: React.FC = () => {
const { user } = useAuth();
if (isGmRole(user?.status)) {
return <GmViewList />;
}
if (isBuhRole(user?.status)) {
return <BuhViewList />;
}
return (
<div>
<PageHeader title="Pengaturan" subtitle="Daftar site di dasbor ini" />
<Banner kind="error">
Pengaturan daftar tampilan tersedia untuk GM, GM Admin, BUH, dan BU Admin.
</Banner>
</div>
);
};
export default SettingsPage;