558 lines
19 KiB
TypeScript
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 { CitySite, 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 [citySites, setCitySites] = useState<CitySite[]>([]);
|
|
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.citySites.list();
|
|
const nextPending = isPrimaryBuh ? await api.users.listPendingManaged() : [];
|
|
setGms(nextUsers.filter((u) => u.status === 'gm'));
|
|
setPending(nextPending);
|
|
setCitySites(nextSites);
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [isPrimaryBuh]);
|
|
|
|
useEffect(() => {
|
|
void reload();
|
|
}, [reload]);
|
|
|
|
const sitesByGm = useMemo(() => {
|
|
const map = new Map<number, CitySite[]>();
|
|
for (const site of citySites) {
|
|
if (site.registered_by == null) continue;
|
|
const rows = map.get(site.registered_by) ?? [];
|
|
rows.push(site);
|
|
map.set(site.registered_by, rows);
|
|
}
|
|
return map;
|
|
}, [citySites]);
|
|
|
|
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 [citySites, setCitySites] = useState<CitySite[]>([]);
|
|
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.citySites.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]);
|
|
setCitySites(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.citySites.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 />
|
|
) : citySites.length === 0 ? (
|
|
<p className="text-sm text-gray-500">Belum ada site di daftar tampilan.</p>
|
|
) : (
|
|
<ul className="divide-y">
|
|
{citySites.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.citySites.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;
|