first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
@@ -0,0 +1,557 @@
|
||||
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.user_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.user_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.user_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;
|
||||
Reference in new issue
Block a user