Files
dashboard-cpsp-executive/components/UserManagementPage.tsx
T
2026-09-08 15:21:23 +07:00

363 lines
13 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts';
import { useAuth } from '../context/AuthContext.tsx';
import type { 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: Record<UserStatus, string> = {
inactive: 'Inactive',
gm: 'GM',
gm_admin: 'GM Admin',
buh: 'BUH',
bu_admin: 'BU Admin',
director: 'Director',
director_admin: 'Director Admin',
account_admin: 'Account Admin',
superuser: 'Superuser',
};
const ASSIGNABLE_STATUSES: UserStatus[] = [
'gm',
'gm_admin',
'buh',
'bu_admin',
'director',
'director_admin',
'account_admin',
'inactive',
];
const UserManagementPage: React.FC = () => {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [userName, setUserName] = useState('');
const [status, setStatus] = useState<UserStatus>('gm');
const [password, setPassword] = useState('');
const [editId, setEditId] = useState<number | null>(null);
const [editStatus, setEditStatus] = useState<UserStatus>('gm');
const [resetId, setResetId] = useState<number | null>(null);
const [resetPassword, setResetPassword] = useState('');
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
setUsers(await api.users.list());
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const wrap = async (fn: () => Promise<void>) => {
setError(null);
setNotice(null);
try {
await fn();
} catch (err) {
setError(errorMessage(err));
}
};
const onCreate = (e: React.FormEvent) => {
e.preventDefault();
void wrap(async () => {
setSaving(true);
try {
await api.users.create({
user_name: userName.trim(),
status,
password,
});
setNotice(
`Akun "${userName.trim()}" dibuat. Bagikan password sementara secara aman (di luar sistem).`
);
setUserName('');
setPassword('');
setStatus('gm');
await reload();
} finally {
setSaving(false);
}
});
};
const onSaveStatus = (id: number) => {
void wrap(async () => {
setBusyId(id);
try {
await api.users.update(id, { status: editStatus });
setNotice('Status pengguna diperbarui.');
setEditId(null);
await reload();
} finally {
setBusyId(null);
}
});
};
const onDeactivate = (user: User) => {
if (!window.confirm(`Nonaktifkan akun "${user.user_name}"?`)) return;
void wrap(async () => {
setBusyId(user.id);
try {
await api.users.update(user.id, { status: 'inactive' });
setNotice(`Akun "${user.user_name}" dinonaktifkan.`);
await reload();
} finally {
setBusyId(null);
}
});
};
const onResetPassword = (id: number) => {
void wrap(async () => {
setBusyId(id);
try {
await api.users.setPassword(id, resetPassword);
setNotice('Password direset. Bagikan password baru secara aman (di luar sistem).');
setResetId(null);
setResetPassword('');
await reload();
} finally {
setBusyId(null);
}
});
};
return (
<div className="space-y-6">
<PageHeader
title="Manajemen Akun"
subtitle="Buat, ubah status, nonaktifkan, dan reset password pengguna executive."
/>
{error && <Banner kind="error">{error}</Banner>}
{notice && <Banner kind="success">{notice}</Banner>}
<section className="bg-white rounded-xl shadow-sm p-5 space-y-4">
<h2 className="text-base font-semibold text-gray-900">Buat pengguna baru</h2>
<form onSubmit={onCreate} className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<label className="block text-sm">
<span className="text-gray-600">Username</span>
<input
className={inputClass}
value={userName}
onChange={(e) => setUserName(e.target.value)}
required
maxLength={30}
autoComplete="off"
/>
</label>
<label className="block text-sm">
<span className="text-gray-600">Role</span>
<select
className={inputClass}
value={status}
onChange={(e) => setStatus(e.target.value as UserStatus)}
>
{ASSIGNABLE_STATUSES.filter((s) => s !== 'inactive').map((s) => (
<option key={s} value={s}>
{ROLE_LABELS[s]}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="text-gray-600">Password sementara</span>
<input
className={inputClass}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
/>
</label>
<div className="flex items-end">
<button
type="submit"
disabled={saving}
className="w-full rounded-md bg-red-600 text-white text-sm font-medium px-4 py-2 hover:bg-red-700 disabled:opacity-60"
>
{saving ? 'Menyimpan…' : 'Buat akun'}
</button>
</div>
</form>
<p className="text-xs text-gray-500">
Password tidak dikirim lewat email. Bagikan secara terpisah ke pengguna.
</p>
</section>
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="text-base font-semibold text-gray-900 mb-4">Daftar pengguna</h2>
{loading ? (
<div className="flex justify-center py-8">
<Spinner />
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-2 pr-4 font-medium">Username</th>
<th className="py-2 pr-4 font-medium">Role</th>
<th className="py-2 pr-4 font-medium">Managed by</th>
<th className="py-2 font-medium">Aksi</th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const isSelf = currentUser?.id === user.id;
return (
<tr key={user.id} className="border-b border-gray-100 align-top">
<td className="py-3 pr-4 font-medium text-gray-900">
{user.user_name}
{isSelf ? (
<span className="ml-2 text-xs font-normal text-gray-500">(Anda)</span>
) : null}
</td>
<td className="py-3 pr-4">
{!isSelf && editId === user.id ? (
<select
className={inputClass}
value={editStatus}
onChange={(e) => setEditStatus(e.target.value as UserStatus)}
>
{ASSIGNABLE_STATUSES.map((s) => (
<option key={s} value={s}>
{ROLE_LABELS[s]}
</option>
))}
</select>
) : (
ROLE_LABELS[user.status] ?? user.status
)}
</td>
<td className="py-3 pr-4 text-gray-600">
{user.managed_by_name ?? '—'}
</td>
<td className="py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
{!isSelf && editId === user.id ? (
<>
<button
type="button"
disabled={busyId === user.id}
onClick={() => onSaveStatus(user.id)}
className="rounded-md bg-red-600 text-white text-xs px-3 py-1.5 hover:bg-red-700 disabled:opacity-60"
>
Simpan
</button>
<button
type="button"
onClick={() => setEditId(null)}
className="rounded-md border border-gray-300 text-xs px-3 py-1.5"
>
Batal
</button>
</>
) : null}
{!isSelf && editId !== user.id ? (
<button
type="button"
onClick={() => {
setEditId(user.id);
setEditStatus(user.status);
setResetId(null);
}}
className="rounded-md border border-gray-300 text-xs px-3 py-1.5"
>
Ubah role
</button>
) : null}
{!isSelf && user.status !== 'inactive' ? (
<button
type="button"
disabled={busyId === user.id}
onClick={() => onDeactivate(user)}
className="rounded-md border border-gray-300 text-xs px-3 py-1.5 text-red-700"
>
Nonaktifkan
</button>
) : null}
{isSelf ? (
<span className="text-xs text-gray-500 self-center">
Role & status akun sendiri tidak bisa diubah di sini.
</span>
) : null}
{resetId === user.id ? (
<div className="flex flex-wrap gap-2 items-center">
<input
className={`${inputClass} w-40`}
type="password"
placeholder="Password baru"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
autoComplete="new-password"
/>
<button
type="button"
disabled={busyId === user.id || !resetPassword.trim()}
onClick={() => onResetPassword(user.id)}
className="rounded-md bg-red-600 text-white text-xs px-3 py-1.5 hover:bg-red-700 disabled:opacity-60"
>
Reset
</button>
<button
type="button"
onClick={() => {
setResetId(null);
setResetPassword('');
}}
className="rounded-md border border-gray-300 text-xs px-3 py-1.5"
>
Batal
</button>
</div>
) : (
<button
type="button"
onClick={() => {
setResetId(user.id);
setResetPassword('');
setEditId(null);
}}
className="rounded-md border border-gray-300 text-xs px-3 py-1.5"
>
Reset password
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{users.length === 0 && (
<p className="text-sm text-gray-500 py-4">Belum ada pengguna.</p>
)}
</div>
)}
</section>
</div>
);
};
export default UserManagementPage;