Initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,319 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function SidebarControls({
|
||||
user,
|
||||
profile,
|
||||
isEditMode,
|
||||
setIsEditMode,
|
||||
setShowApprovalModal,
|
||||
setShowUserModal,
|
||||
fetchUsers,
|
||||
setPlacementMode,
|
||||
setShowToast,
|
||||
filters,
|
||||
setFilters,
|
||||
hubStyles,
|
||||
setShowSurveyModal,
|
||||
setShowCapacityModal,
|
||||
pendingCapacityCount,
|
||||
setShowDelegationModal,
|
||||
pendingDelegationsCount,
|
||||
hubs,
|
||||
onFocusHub,
|
||||
onOpenKas,
|
||||
setShowPicListModal,
|
||||
onlyMyHub,
|
||||
setOnlyMyHub
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{user && (profile?.role === 'super_admin' || profile?.role === 'pic') && (
|
||||
<div className="edit-mode-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||
<i className="fas fa-edit"></i> Mode Edit Peta
|
||||
</h4>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setIsEditMode(!isEditMode)}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: isEditMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(59, 130, 246, 0.15)',
|
||||
color: isEditMode ? 'var(--danger)' : 'var(--primary)',
|
||||
border: isEditMode ? '1px solid rgba(239, 68, 68, 0.3)' : '1px solid rgba(59, 130, 246, 0.3)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<i className={`fas ${isEditMode ? 'fa-lock-open' : 'fa-lock'}`}></i>
|
||||
<span>{isEditMode ? 'Nonaktifkan Mode Edit' : 'Aktifkan Mode Edit'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && profile?.role === 'super_admin' && (
|
||||
<div className="admin-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '10px' }}>
|
||||
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||
<i className="fas fa-user-shield"></i> Aksi Admin
|
||||
</h4>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowApprovalModal(true)}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-list-check" style={{ color: 'var(--primary)' }}></i>
|
||||
<span>Verifikasi KK</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowCapacityModal(true)}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-triangle-exclamation" style={{ color: '#d97706' }}></i>
|
||||
<span>Kapasitas Hub</span>
|
||||
{pendingCapacityCount > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
right: '16px',
|
||||
background: 'var(--danger)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{pendingCapacityCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => { fetchUsers(); setShowUserModal(true); }}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-users-cog" style={{ color: 'var(--primary)' }}></i>
|
||||
<span>Manajemen Pengguna</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && (profile?.role === 'pic' || profile?.role === 'super_admin') && isEditMode && (
|
||||
<div className="surveyor-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||
<i className="fas fa-route"></i> Menu Surveyor
|
||||
</h4>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowSurveyModal(true)}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: 'rgba(16, 185, 129, 0.15)',
|
||||
color: 'var(--success)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-house-medical" style={{ color: 'var(--success)' }}></i>
|
||||
<span>Tambah Pendataan KK</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && profile?.role === 'pic' && (
|
||||
<div className="pic-hub-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '5px' }}>
|
||||
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||
<i className="fas fa-mosque"></i> Hub Saya
|
||||
</h4>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
const myHub = hubs.find(h => h.pic_id === profile.id);
|
||||
if (myHub) onFocusHub(myHub);
|
||||
}}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-search-location" style={{ color: 'var(--primary)' }}></i>
|
||||
<span>Fokus ke Hub Saya</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && profile?.role === 'hub_head' && (
|
||||
<div className="delegation-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '5px' }}>
|
||||
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||
<i className="fas fa-handshake"></i> Koordinasi Hub
|
||||
</h4>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowDelegationModal(true)}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-exchange-alt" style={{ color: 'var(--primary)' }}></i>
|
||||
<span>Delegasi Masuk</span>
|
||||
{pendingDelegationsCount > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
right: '16px',
|
||||
background: 'var(--danger)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{pendingDelegationsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && profile?.role === 'hub_head' && (
|
||||
<div className="manager-controls" style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '5px' }}>
|
||||
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||
<i className="fas fa-mosque"></i> Menu Ketua Pengurus
|
||||
</h4>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
const myHub = hubs.find(h => h.head_id === profile.id);
|
||||
if (myHub) onFocusHub(myHub);
|
||||
}}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-search-location" style={{ color: 'var(--primary)' }}></i>
|
||||
<span>Fokus ke Hub Saya</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
const myHub = hubs.find(h => h.head_id === profile.id);
|
||||
if (myHub) onOpenKas(myHub);
|
||||
}}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-wallet" style={{ color: 'var(--success)' }}></i>
|
||||
<span>Manajemen Kas</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
fetchUsers();
|
||||
setShowPicListModal(true);
|
||||
}}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-users" style={{ color: '#d97706' }}></i>
|
||||
<span>Akun PIC Lapangan</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditMode && profile?.role === 'super_admin' && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => { setPlacementMode('hub'); setShowToast(true); }}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
background: 'rgba(59, 130, 246, 0.15)',
|
||||
color: 'var(--primary)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-place-of-worship"></i>
|
||||
<span>Tambah Rumah Ibadah</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="filter-group">
|
||||
<h4><i className="fas fa-filter"></i> Filter Visibilitas</h4>
|
||||
{user && (profile?.role === 'pic' || profile?.role === 'hub_head') && (
|
||||
<label className="checkbox-item" style={{ marginBottom: '8px', borderBottom: '1px dashed rgba(0,0,0,0.1)', paddingBottom: '8px', fontWeight: 'bold' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyMyHub}
|
||||
onChange={(e) => setOnlyMyHub(e.target.checked)}
|
||||
/>
|
||||
<i className="fas fa-location-crosshairs" style={{ color: 'var(--primary)', width: '20px' }}></i> Hanya Hub Saya
|
||||
</label>
|
||||
)}
|
||||
{Object.keys(filters).map(key => (
|
||||
<label className="checkbox-item" key={key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters[key]}
|
||||
onChange={(e) => setFilters({ ...filters, [key]: e.target.checked })}
|
||||
/>
|
||||
<i className={hubStyles[key].iconClass} style={{ color: hubStyles[key].color, width: '20px' }}></i> {key === 'Mosque' ? 'Masjid' : key === 'Church' ? 'Gereja' : key === 'Temple' ? 'Pura' : key === 'Vihara' ? 'Vihara' : 'Lainnya'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function StatsPanel({
|
||||
profile,
|
||||
showStats,
|
||||
setShowStats,
|
||||
stats
|
||||
}) {
|
||||
return (
|
||||
<div className={`right-panel glass-panel ${showStats ? '' : 'hidden'}`} id="statsPanel">
|
||||
<div className="panel-title">
|
||||
<span className="title-text"><i className="fas fa-chart-pie"></i> {profile?.role === 'hub_head' || profile?.role === 'pic' ? 'Micro Statistics (Your Area)' : 'Macro Statistics (City Level)'}</span>
|
||||
<button className="btn-toggle-panel" onClick={() => setShowStats(false)}><i className="fas fa-chevron-right"></i> Hide</button>
|
||||
</div>
|
||||
|
||||
<div className="kpi-container">
|
||||
<div className="kpi-card text-center">
|
||||
<div style={{ fontSize: '2rem', color: 'var(--success)', fontWeight: 'bold' }}>{stats.coveredCount}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#64748b' }}>Warga Terjangkau</div>
|
||||
</div>
|
||||
<div className="kpi-card text-center">
|
||||
<div style={{ fontSize: '2rem', color: 'var(--danger)', fontWeight: 'bold' }}>{stats.uncoveredCount}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#64748b' }}>Warga Belum Terjangkau</div>
|
||||
</div>
|
||||
<div className="kpi-card text-center">
|
||||
<div style={{ fontSize: '2rem', color: 'var(--primary)', fontWeight: 'bold' }}>{stats.equalityIndex}%</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#64748b' }}>Indeks Pemerataan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 20px', display: 'flex', gap: '20px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4 style={{ margin: '10px 0 5px 0', fontSize: '0.85rem', color: '#475569', textAlign: 'center' }}>Sebaran per Wilayah</h4>
|
||||
<canvas id="subDistrictChart" style={{ width: '100%', height: '200px' }}></canvas>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4 style={{ margin: '10px 0 5px 0', fontSize: '0.85rem', color: '#475569', textAlign: 'center' }}>Berdasarkan Tipe Hub</h4>
|
||||
<canvas id="typeChart" style={{ width: '100%', height: '200px' }}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
export default function ApprovalQueueModal({
|
||||
showApprovalModal,
|
||||
setShowApprovalModal,
|
||||
households,
|
||||
selectedPhotoUrl,
|
||||
setSelectedPhotoUrl,
|
||||
handleVerifyAction,
|
||||
onFocusHousehold
|
||||
}) {
|
||||
if (!showApprovalModal) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modal-overlay active">
|
||||
<div className="modal-content" style={{ maxWidth: '900px', width: '90%' }}>
|
||||
<div className="modal-header">
|
||||
<h2><i className="fas fa-list-check text-primary"></i> Antrean Verifikasi KK</h2>
|
||||
<button className="close-btn" onClick={() => setShowApprovalModal(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
|
||||
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Kepala Keluarga</th>
|
||||
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Jml Anggota</th>
|
||||
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Wilayah</th>
|
||||
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Aksi Lokasi / Foto</th>
|
||||
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0', textAlign: 'right' }}>Aksi Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{households.filter(hh => hh.status === 'Pending').length === 0 ? (
|
||||
<tr><td colSpan="5" style={{ textAlign: 'center', padding: '20px', color: '#64748b' }}>Tidak ada antrean pending.</td></tr>
|
||||
) : (
|
||||
households.filter(hh => hh.status === 'Pending').map(hh => (
|
||||
<tr key={hh.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<span style={{ fontWeight: 'bold' }}>{hh.head_of_family}</span>
|
||||
<br/><small style={{ color: '#64748b' }}>NIK: {hh.nik || '-'}</small>
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>{hh.family_size} Jiwa</td>
|
||||
<td style={{ padding: '10px' }}>{hh.sub_district}</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button onClick={async () => {
|
||||
if (hh.photo_url && !hh.photo_url.startsWith('http')) {
|
||||
const { data } = await supabase.storage.from('geotagging-photo').createSignedUrl(hh.photo_url, 3600);
|
||||
if (data && data.signedUrl) setSelectedPhotoUrl(data.signedUrl);
|
||||
} else {
|
||||
setSelectedPhotoUrl(hh.photo_url || 'https://via.placeholder.com/150');
|
||||
}
|
||||
}} className="btn btn-outline" style={{ padding: '4px 8px', fontSize: '0.8rem' }}>Lihat Foto</button>
|
||||
|
||||
<button onClick={() => onFocusHousehold(hh)} className="btn btn-outline" style={{ padding: '4px 8px', fontSize: '0.8rem', color: 'var(--primary)', borderColor: 'rgba(59, 130, 246, 0.3)' }} title="Fokus Peta ke Lokasi KK">
|
||||
<i className="fas fa-crosshairs"></i> Lihat Lokasi
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px', textAlign: 'right' }}>
|
||||
<button onClick={() => handleVerifyAction(hh.id, 'Verified')} className="btn btn-primary" style={{ padding: '4px 8px', fontSize: '0.8rem', background: '#10b981', borderColor: '#10b981', color: 'white', marginRight: '5px' }}>Terima</button>
|
||||
<button onClick={() => handleVerifyAction(hh.id, 'Rejected')} className="btn btn-outline" style={{ padding: '4px 8px', fontSize: '0.8rem', color: '#ef4444', borderColor: '#ef4444' }}>Tolak</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Preview Overlay */}
|
||||
{selectedPhotoUrl && (
|
||||
<div className="modal-overlay active" style={{ zIndex: 9999 }} onClick={() => setSelectedPhotoUrl('')}>
|
||||
<div style={{ position: 'relative', maxWidth: '90%', maxHeight: '90%' }}>
|
||||
<button onClick={() => setSelectedPhotoUrl('')} style={{ position: 'absolute', top: '-40px', right: '0', background: 'none', border: 'none', color: 'white', fontSize: '2rem', cursor: 'pointer' }}>×</button>
|
||||
<img src={selectedPhotoUrl} alt="Bukti Foto" style={{ maxWidth: '100%', maxHeight: '90vh', borderRadius: '8px', boxShadow: '0 4px 20px rgba(0,0,0,0.5)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export default function CapacityApprovalModal({
|
||||
showApprovalModal,
|
||||
setShowApprovalModal,
|
||||
hubs,
|
||||
households,
|
||||
showToastNotification,
|
||||
onSuccess
|
||||
}) {
|
||||
const [selectedHub, setSelectedHub] = useState(null);
|
||||
const [hubDonations, setHubDonations] = useState([]);
|
||||
const [isLoadingFinancials, setIsLoadingFinancials] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Filter hubs that have pending requests
|
||||
const pendingHubs = hubs.filter(h => h.pending_capacity_status !== null && h.pending_capacity_status !== undefined);
|
||||
|
||||
// Auto-select first pending hub
|
||||
useEffect(() => {
|
||||
if (pendingHubs.length > 0 && !selectedHub) {
|
||||
setSelectedHub(pendingHubs[0]);
|
||||
} else if (pendingHubs.length === 0) {
|
||||
setSelectedHub(null);
|
||||
}
|
||||
}, [hubs, pendingHubs, selectedHub]);
|
||||
|
||||
// Fetch selected hub's financials
|
||||
useEffect(() => {
|
||||
if (selectedHub) {
|
||||
const fetchHubFinancials = async () => {
|
||||
setIsLoadingFinancials(true);
|
||||
const { data, error } = await supabase
|
||||
.from('donations')
|
||||
.select('*')
|
||||
.eq('hub_id', selectedHub.id)
|
||||
.order('transaction_date', { ascending: false });
|
||||
if (error) {
|
||||
console.error('Error fetching hub donations:', error);
|
||||
} else {
|
||||
setHubDonations(data || []);
|
||||
}
|
||||
setIsLoadingFinancials(false);
|
||||
};
|
||||
fetchHubFinancials();
|
||||
} else {
|
||||
setHubDonations([]);
|
||||
}
|
||||
}, [selectedHub]);
|
||||
|
||||
if (!showApprovalModal) return null;
|
||||
|
||||
// Calculations for selected hub
|
||||
const servedKkCount = selectedHub
|
||||
? households.filter(hh => hh.db_assigned_hub_id === selectedHub.id).length
|
||||
: 0;
|
||||
|
||||
const totalBalance = hubDonations.reduce((acc, curr) => {
|
||||
if (curr.type === 'IN') return acc + Number(curr.amount);
|
||||
return acc - Number(curr.amount);
|
||||
}, 0);
|
||||
|
||||
const ratioPerKk = servedKkCount > 0 ? totalBalance / servedKkCount : totalBalance;
|
||||
|
||||
// Determine critical state warning and recommendations
|
||||
let recommendationText = 'Kondisi Aman';
|
||||
let recommendationColor = '#10b981';
|
||||
let recommendationIcon = 'fa-check-circle';
|
||||
|
||||
if (ratioPerKk < 100000) {
|
||||
recommendationText = 'SANGAT KRITIS (Rekomendasi Setujui: Valid)';
|
||||
recommendationColor = '#ef4444';
|
||||
recommendationIcon = 'fa-exclamation-triangle';
|
||||
} else if (ratioPerKk < 300000) {
|
||||
recommendationText = 'KRITIS / SEDANG (Rekomendasi Setujui: Cukup Valid)';
|
||||
recommendationColor = '#f59e0b';
|
||||
recommendationIcon = 'fa-circle-info';
|
||||
} else {
|
||||
recommendationText = 'KONDISI SEHAT (Rekomendasi Tolak: Dana Cukup)';
|
||||
recommendationColor = '#3b82f6';
|
||||
recommendationIcon = 'fa-shield-halved';
|
||||
}
|
||||
|
||||
// Handle decisions
|
||||
const handleDecision = async (hubId, approve) => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (approve) {
|
||||
// Overwrite main status and reset pending status
|
||||
const { error } = await supabase
|
||||
.from('hubs')
|
||||
.update({
|
||||
capacity_status: selectedHub.pending_capacity_status,
|
||||
pending_capacity_status: null
|
||||
})
|
||||
.eq('id', hubId);
|
||||
|
||||
if (error) {
|
||||
alert(`Gagal menyetujui pengajuan: ${error.message}`);
|
||||
} else {
|
||||
showToastNotification(`Berhasil menyetujui perubahan kapasitas menjadi overloaded!`);
|
||||
setSelectedHub(null);
|
||||
onSuccess();
|
||||
}
|
||||
} else {
|
||||
// Just reject by resetting pending status
|
||||
const { error } = await supabase
|
||||
.from('hubs')
|
||||
.update({ pending_capacity_status: null })
|
||||
.eq('id', hubId);
|
||||
|
||||
if (error) {
|
||||
alert(`Gagal menolak pengajuan: ${error.message}`);
|
||||
} else {
|
||||
showToastNotification(`Pengajuan perubahan kapasitas ditolak.`);
|
||||
setSelectedHub(null);
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay active">
|
||||
<div className="modal-content" style={{ maxWidth: '950px', width: '95%' }}>
|
||||
<div className="modal-header">
|
||||
<h2><i className="fas fa-list-check text-primary"></i> Antrean Verifikasi Kapasitas Hub</h2>
|
||||
<button className="close-btn" onClick={() => setShowApprovalModal(false)}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: '20px', maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
|
||||
{/* Left Column: Pending Hubs List */}
|
||||
<div style={{ borderRight: '1px solid #e2e8f0', paddingRight: '15px' }}>
|
||||
<h3 style={{ margin: '0 0 15px 0', fontSize: '1rem', color: 'var(--text-muted)' }}>Daftar Pengajuan ({pendingHubs.length})</h3>
|
||||
{pendingHubs.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: '0.9rem', padding: '40px 10px' }}>
|
||||
Tidak ada pengajuan perubahan kapasitas saat ini.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{pendingHubs.map(hub => {
|
||||
const isSelected = selectedHub?.id === hub.id;
|
||||
return (
|
||||
<div
|
||||
key={hub.id}
|
||||
onClick={() => setSelectedHub(hub)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: isSelected ? '2px solid var(--primary)' : '1px solid #e2e8f0',
|
||||
background: isSelected ? 'rgba(59, 130, 246, 0.05)' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: 'block', fontSize: '0.9rem', color: '#1e293b' }}>{hub.name}</strong>
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748b' }}>{hub.type}</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '8px' }}>
|
||||
<span style={{ fontSize: '0.7rem', padding: '2px 6px', background: '#fef3c7', color: '#d97706', borderRadius: '4px', fontWeight: 'bold' }}>
|
||||
Minta: {hub.pending_capacity_status === 'overloaded' ? 'Overloaded' : 'Normal'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Selected Hub Details and Ratio Analysis */}
|
||||
<div>
|
||||
{selectedHub ? (
|
||||
<div>
|
||||
{/* Hub Header */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3 style={{ margin: '0 0 4px 0', fontSize: '1.25rem', color: '#1e293b' }}>{selectedHub.name}</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}>
|
||||
<i className="fas fa-map-marker-alt" style={{ marginRight: '6px' }}></i>{selectedHub.address || 'Alamat belum diatur'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metrics Cards Grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '15px', marginBottom: '20px' }}>
|
||||
|
||||
{/* Card 1: Assigned KK */}
|
||||
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
||||
<span style={{ display: 'block', fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold', textTransform: 'uppercase' }}>KK Terlayani</span>
|
||||
<strong style={{ fontSize: '1.5rem', color: '#1e293b' }}>{servedKkCount} <span style={{ fontSize: '0.85rem', fontWeight: 'normal' }}>Keluarga</span></strong>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Donation Balance */}
|
||||
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
||||
<span style={{ display: 'block', fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold', textTransform: 'uppercase' }}>Saldo Kas</span>
|
||||
{isLoadingFinancials ? (
|
||||
<span style={{ fontSize: '0.9rem', color: '#94a3b8' }}>Memuat data...</span>
|
||||
) : (
|
||||
<strong style={{ fontSize: '1.5rem', color: totalBalance >= 0 ? '#10b981' : '#ef4444' }}>
|
||||
Rp{totalBalance.toLocaleString('id-ID')}
|
||||
</strong>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card 3: Financial Ratio */}
|
||||
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
||||
<span style={{ display: 'block', fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold', textTransform: 'uppercase' }}>Rasio Dana / KK</span>
|
||||
{isLoadingFinancials ? (
|
||||
<span style={{ fontSize: '0.9rem', color: '#94a3b8' }}>Memuat data...</span>
|
||||
) : (
|
||||
<strong style={{ fontSize: '1.25rem', color: ratioPerKk < 100000 ? '#ef4444' : '#1e293b' }}>
|
||||
Rp{Math.round(ratioPerKk).toLocaleString('id-ID')} <span style={{ fontSize: '0.75rem', fontWeight: 'normal', color: '#64748b' }}>/ KK</span>
|
||||
</strong>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Audit & Recommendation Box */}
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
padding: '15px',
|
||||
borderRadius: '8px',
|
||||
borderLeft: `4px solid ${recommendationColor}`,
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<i className={`fas ${recommendationIcon}`} style={{ color: recommendationColor, fontSize: '1.5rem' }}></i>
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '0.85rem', color: '#1e293b' }}>Rekomendasi Analitis Pusat:</strong>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 'bold', color: recommendationColor }}>{recommendationText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last 5 Transactions List */}
|
||||
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', border: '1px solid #e2e8f0', marginBottom: '25px' }}>
|
||||
<h4 style={{ margin: '0 0 10px 0', fontSize: '0.9rem', color: '#1e293b' }}><i className="fas fa-history"></i> Riwayat Transaksi Terakhir</h4>
|
||||
{isLoadingFinancials ? (
|
||||
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: '0.85rem', padding: '10px 0' }}>Memuat data keuangan...</p>
|
||||
) : hubDonations.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: '0.85rem', padding: '10px 0' }}>Tidak ada riwayat transaksi kas.</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#e2e8f0', textAlign: 'left' }}>
|
||||
<th style={{ padding: '6px', borderBottom: '1px solid #cbd5e1' }}>Tanggal</th>
|
||||
<th style={{ padding: '6px', borderBottom: '1px solid #cbd5e1' }}>Keterangan</th>
|
||||
<th style={{ padding: '6px', borderBottom: '1px solid #cbd5e1', textAlign: 'right' }}>Nominal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hubDonations.slice(0, 5).map(donation => (
|
||||
<tr key={donation.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '6px', color: '#475569' }}>{donation.transaction_date}</td>
|
||||
<td style={{ padding: '6px', color: '#1e293b' }}>{donation.description}</td>
|
||||
<td style={{ padding: '6px', textAlign: 'right', fontWeight: 'bold', color: donation.type === 'IN' ? '#10b981' : '#ef4444' }}>
|
||||
{donation.type === 'IN' ? '+' : '-'} Rp{Number(donation.amount).toLocaleString('id-ID')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions Buttons */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleDecision(selectedHub.id, false)}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#fee2e2',
|
||||
color: '#b91c1c',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
fontSize: '0.85rem'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-times-circle"></i> Tolak Pengajuan
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleDecision(selectedHub.id, true)}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
fontSize: '0.85rem'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-check-circle"></i> Setujui Pengajuan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '40vh', color: '#94a3b8' }}>
|
||||
<i className="fas fa-arrow-left" style={{ fontSize: '2rem', marginBottom: '10px' }}></i>
|
||||
<p style={{ margin: 0 }}>Silakan pilih salah satu Hub dari antrean di sebelah kiri untuk diaudit.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import React from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
// Simple Haversine distance calculator
|
||||
function getDistance(lat1, lon1, lat2, lon2) {
|
||||
if (!lat1 || !lon1 || !lat2 || !lon2) return 0;
|
||||
const R = 6371000; // meters
|
||||
const phi1 = (lat1 * Math.PI) / 180;
|
||||
const phi2 = (lat2 * Math.PI) / 180;
|
||||
const deltaPhi = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const deltaLambda = ((lon2 - lon1) * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c; // in meters
|
||||
}
|
||||
|
||||
export default function DelegationQueueModal({
|
||||
showDelegationModal,
|
||||
setShowDelegationModal,
|
||||
households,
|
||||
hubs,
|
||||
myHub,
|
||||
selectedPhotoUrl,
|
||||
setSelectedPhotoUrl,
|
||||
onFocusHousehold,
|
||||
fetchData,
|
||||
showToastNotification
|
||||
}) {
|
||||
if (!showDelegationModal || !myHub) return null;
|
||||
|
||||
// Filter incoming transfers targeted to myHub
|
||||
const incomingDelegations = households.filter(hh => hh.pending_transfer_hub_id === myHub.id);
|
||||
|
||||
const handleAccept = async (hh) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('households')
|
||||
.update({
|
||||
assigned_hub_id: myHub.id,
|
||||
pending_transfer_hub_id: null,
|
||||
manual_override_assigned_hub: true
|
||||
})
|
||||
.eq('id', hh.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
showToastNotification(`Warga ${hh.head_of_family} berhasil diterima dan dialihkan ke Hub Anda!`, 'success');
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToastNotification(`Gagal menerima delegasi: ${err.message}`, 'danger');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (hh) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('households')
|
||||
.update({
|
||||
pending_transfer_hub_id: null
|
||||
})
|
||||
.eq('id', hh.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
showToastNotification(`Delegasi warga ${hh.head_of_family} berhasil ditolak.`, 'success');
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToastNotification(`Gagal menolak delegasi: ${err.message}`, 'danger');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modal-overlay active">
|
||||
<div className="modal-content" style={{ maxWidth: '950px', width: '90%' }}>
|
||||
<div className="modal-header">
|
||||
<h2>
|
||||
<i className="fas fa-exchange-alt text-primary" style={{ marginRight: '8px' }}></i>
|
||||
Permohonan Delegasi Masuk
|
||||
</h2>
|
||||
<button className="close-btn" onClick={() => setShowDelegationModal(false)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
|
||||
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0' }}>Kepala Keluarga</th>
|
||||
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0' }}>Jumlah Anggota</th>
|
||||
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0' }}>Hub Asal</th>
|
||||
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0' }}>Jarak ke Hub Anda</th>
|
||||
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0' }}>Verifikasi Media</th>
|
||||
<th style={{ padding: '12px 10px', borderBottom: '2px solid #e2e8f0', textAlign: 'right' }}>Aksi Keputusan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incomingDelegations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" style={{ textAlign: 'center', padding: '30px', color: '#64748b' }}>
|
||||
Tidak ada permohonan delegasi masuk untuk Hub Anda.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
incomingDelegations.map((hh) => {
|
||||
const originHubName = hubs.find((h) => String(h.id) === String(hh.db_assigned_hub_id))?.name || 'Hub Tidak Dikenal';
|
||||
const distanceMeters = getDistance(hh.lat, hh.lng, myHub.lat, myHub.lng);
|
||||
const formattedDistance =
|
||||
distanceMeters > 1000
|
||||
? `${(distanceMeters / 1000).toFixed(2)} km`
|
||||
: `${Math.round(distanceMeters)} m`;
|
||||
|
||||
return (
|
||||
<tr key={hh.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '12px 10px' }}>
|
||||
<span style={{ fontWeight: 'bold' }}>{hh.head_of_family}</span>
|
||||
<br />
|
||||
<small style={{ color: '#64748b' }}>NIK: {hh.nik || '-'}</small>
|
||||
</td>
|
||||
<td style={{ padding: '12px 10px' }}>{hh.family_size} Jiwa</td>
|
||||
<td style={{ padding: '12px 10px', color: '#4b5563', fontWeight: '500' }}>
|
||||
{originHubName}
|
||||
</td>
|
||||
<td style={{ padding: '12px 10px' }}>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
background: distanceMeters > (myHub.radius || 500) ? '#fef3c7' : '#dcfce7',
|
||||
color: distanceMeters > (myHub.radius || 500) ? '#d97706' : '#15803d',
|
||||
fontWeight: '600',
|
||||
fontSize: '0.8rem'
|
||||
}}
|
||||
>
|
||||
{formattedDistance}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 10px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (hh.photo_url && !hh.photo_url.startsWith('http')) {
|
||||
const { data } = await supabase.storage.from('geotagging-photo').createSignedUrl(hh.photo_url, 3600);
|
||||
if (data && data.signedUrl) setSelectedPhotoUrl(data.signedUrl);
|
||||
} else {
|
||||
setSelectedPhotoUrl(hh.photo_url || 'https://via.placeholder.com/150');
|
||||
}
|
||||
}}
|
||||
className="btn btn-outline"
|
||||
style={{ padding: '4px 8px', fontSize: '0.8rem' }}
|
||||
>
|
||||
Foto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onFocusHousehold(hh);
|
||||
setShowDelegationModal(false);
|
||||
}}
|
||||
className="btn btn-outline"
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--primary)',
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)'
|
||||
}}
|
||||
title="Fokus Peta ke Lokasi KK"
|
||||
>
|
||||
<i className="fas fa-crosshairs"></i> Peta
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 10px', textAlign: 'right' }}>
|
||||
<button
|
||||
onClick={() => handleAccept(hh)}
|
||||
className="btn btn-primary"
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.8rem',
|
||||
background: '#10b981',
|
||||
borderColor: '#10b981',
|
||||
color: 'white',
|
||||
marginRight: '6px'
|
||||
}}
|
||||
>
|
||||
Terima
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReject(hh)}
|
||||
className="btn btn-outline"
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.8rem',
|
||||
color: '#ef4444',
|
||||
borderColor: '#ef4444'
|
||||
}}
|
||||
>
|
||||
Tolak
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Preview Overlay */}
|
||||
{selectedPhotoUrl && (
|
||||
<div className="modal-overlay active" style={{ zIndex: 9999 }} onClick={() => setSelectedPhotoUrl('')}>
|
||||
<div style={{ position: 'relative', maxWidth: '90%', maxHeight: '90%' }}>
|
||||
<button
|
||||
onClick={() => setSelectedPhotoUrl('')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-40px',
|
||||
right: '0',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
fontSize: '2rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
src={selectedPhotoUrl}
|
||||
alt="Bukti Foto"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '90vh',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.5)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function DonationModal({
|
||||
showDonationModal,
|
||||
setShowDonationModal,
|
||||
donationFormData,
|
||||
setDonationFormData,
|
||||
hubs,
|
||||
isSubmittingDonation,
|
||||
handleSaveDonation,
|
||||
donationsList,
|
||||
profile
|
||||
}) {
|
||||
if (!showDonationModal) return null;
|
||||
if (!profile) return null; // Secure fail-safe for unauthenticated users
|
||||
|
||||
// Check roles: PIC, Hub Head, and Super Admin can record transactions
|
||||
const canEdit = profile.role === 'super_admin' || profile.role === 'pic' || profile.role === 'hub_head';
|
||||
|
||||
// Calculate Running / Total Balance
|
||||
const totalBalance = donationsList.reduce((acc, curr) => {
|
||||
if (curr.type === 'IN') {
|
||||
return acc + Number(curr.amount);
|
||||
} else {
|
||||
return acc - Number(curr.amount);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Filter hubs list based on role
|
||||
const allowedHubs = profile.role === 'super_admin'
|
||||
? hubs
|
||||
: hubs.filter(h => h.pic_id === profile.id || h.head_id === profile.id);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay active">
|
||||
<div className="modal-content" style={{ maxWidth: '800px', width: '90%' }}>
|
||||
<div className="modal-header">
|
||||
<h2><i className="fas fa-hand-holding-heart text-primary"></i> Manajemen Donasi / Kas</h2>
|
||||
<button className="close-btn" onClick={() => setShowDonationModal(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: canEdit ? '1fr 1fr' : '1fr',
|
||||
gap: '20px',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
|
||||
{/* Form Transaksi (Only for PIC & Super Admin) */}
|
||||
{canEdit && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 15px 0', fontSize: '1.1rem' }}>Tambah Transaksi</h3>
|
||||
<form onSubmit={handleSaveDonation} style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
||||
<div className="form-group">
|
||||
<label>Rumah Ibadah</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={donationFormData.hub_id}
|
||||
onChange={(e) => setDonationFormData({...donationFormData, hub_id: e.target.value})}
|
||||
required
|
||||
disabled={profile.role !== 'super_admin'}
|
||||
>
|
||||
<option value="">Pilih Rumah Ibadah</option>
|
||||
{allowedHubs.map(h => <option key={h.id} value={h.id}>{h.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Jenis Transaksi</label>
|
||||
<select className="form-control" value={donationFormData.type} onChange={(e) => setDonationFormData({...donationFormData, type: e.target.value})} required>
|
||||
<option value="IN">Pemasukan (Donasi/Sedekah)</option>
|
||||
<option value="OUT">Pengeluaran (Bantuan/Operasional)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Nominal (Rp)</label>
|
||||
<input type="number" className="form-control" value={donationFormData.amount} onChange={(e) => setDonationFormData({...donationFormData, amount: e.target.value})} min="1" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Keterangan</label>
|
||||
<textarea className="form-control" value={donationFormData.description} onChange={(e) => setDonationFormData({...donationFormData, description: e.target.value})} required rows="3" placeholder="Contoh: Sedekah hamba Allah / Bantuan Sembako"></textarea>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Tanggal Transaksi</label>
|
||||
<input type="date" className="form-control" value={donationFormData.transaction_date} onChange={(e) => setDonationFormData({...donationFormData, transaction_date: e.target.value})} required />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={isSubmittingDonation} style={{ width: '100%', padding: '10px' }}>
|
||||
<i className="fas fa-save"></i> {isSubmittingDonation ? 'Menyimpan...' : 'Simpan Transaksi'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction Logs */}
|
||||
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px', flexWrap: 'wrap', gap: '10px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>Riwayat Transaksi</h3>
|
||||
<div style={{
|
||||
padding: '6px 12px',
|
||||
background: totalBalance >= 0 ? '#dcfce7' : '#fee2e2',
|
||||
color: totalBalance >= 0 ? '#15803d' : '#b91c1c',
|
||||
borderRadius: '20px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.85rem'
|
||||
}}>
|
||||
Saldo Kas: Rp{totalBalance.toLocaleString('id-ID')}
|
||||
</div>
|
||||
</div>
|
||||
{donationsList.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: '0.9rem', padding: '20px 0' }}>Belum ada data transaksi.</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#e2e8f0', textAlign: 'left' }}>
|
||||
<th style={{ padding: '8px', borderBottom: '2px solid #cbd5e1' }}>Tanggal</th>
|
||||
<th style={{ padding: '8px', borderBottom: '2px solid #cbd5e1' }}>Keterangan</th>
|
||||
<th style={{ padding: '8px', borderBottom: '2px solid #cbd5e1', textAlign: 'right' }}>Nominal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{donationsList.map(d => (
|
||||
<tr key={d.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '8px', color: '#475569' }}>{d.transaction_date}</td>
|
||||
<td style={{ padding: '8px' }}>
|
||||
<span style={{ fontWeight: '500', color: '#1e293b' }}>{d.description}</span>
|
||||
{d.profiles && d.profiles.full_name && <div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>Oleh: {d.profiles.full_name}</div>}
|
||||
</td>
|
||||
<td style={{ padding: '8px', textAlign: 'right', fontWeight: 'bold', color: d.type === 'IN' ? '#10b981' : '#ef4444' }}>
|
||||
{d.type === 'IN' ? '+' : '-'} Rp{Number(d.amount).toLocaleString('id-ID')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function HouseholdFormModal({
|
||||
showHhModal,
|
||||
setShowHhModal,
|
||||
hhFormData,
|
||||
setHhFormData,
|
||||
handleSaveHh,
|
||||
hubs,
|
||||
imageFile,
|
||||
setImageFile,
|
||||
isUploading,
|
||||
isEditMode,
|
||||
profile
|
||||
}) {
|
||||
if (!showHhModal) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay active">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h2>{hhFormData.id ? 'Edit' : 'Tambah'} Data Warga</h2>
|
||||
<button className="close-btn" onClick={() => setShowHhModal(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleSaveHh} style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
||||
<div className="form-group">
|
||||
<label>Nama Kepala Keluarga</label>
|
||||
<input type="text" className="form-control" value={hhFormData.head_name} onChange={e => setHhFormData({...hhFormData, head_name: e.target.value})} required />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-group">
|
||||
<label>NIK</label>
|
||||
<input type="text" className="form-control" value={hhFormData.nik} onChange={e => setHhFormData({...hhFormData, nik: e.target.value})} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>No. KK</label>
|
||||
<input type="text" className="form-control" value={hhFormData.no_kk} onChange={e => setHhFormData({...hhFormData, no_kk: e.target.value})} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Jumlah Anggota Keluarga</label>
|
||||
<input type="number" className="form-control" value={hhFormData.family_size} onChange={e => setHhFormData({...hhFormData, family_size: e.target.value})} required min="1" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Pilih Hub Rumah Ibadah {profile?.role === 'pic' ? '(Terkunci untuk Hub Anda)' : '(Opsional)'}</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={hhFormData.assigned_hub_id || ''}
|
||||
onChange={e => setHhFormData({...hhFormData, assigned_hub_id: e.target.value || null})}
|
||||
disabled={profile?.role === 'pic'}
|
||||
>
|
||||
<option value="">-- Tidak Ada --</option>
|
||||
{hubs.map(h => (
|
||||
<option key={h.id} value={h.id}>{h.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Geotagging Photo Upload */}
|
||||
<div className="form-group" style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', border: '1px dashed #cbd5e1' }}>
|
||||
<label style={{ display: 'block', marginBottom: '10px', fontWeight: 'bold' }}><i className="fas fa-camera"></i> Bukti Foto Lokasi / Rumah</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setImageFile(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%', fontSize: '0.9rem' }}
|
||||
required={false}
|
||||
/>
|
||||
{imageFile && <p style={{ fontSize: '0.8rem', color: '#10b981', marginTop: '5px' }}>File siap diunggah: {imageFile.name}</p>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-group">
|
||||
<label>Latitude</label>
|
||||
<input type="text" className="form-control" value={hhFormData.lat} readOnly />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Longitude</label>
|
||||
<input type="text" className="form-control" value={hhFormData.lng} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<button type="submit" className="btn btn-primary" disabled={isUploading}>
|
||||
{isUploading ? 'Menyimpan...' : 'Simpan'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function HubFormModal({
|
||||
showHubModal,
|
||||
setShowHubModal,
|
||||
hubFormData,
|
||||
setHubFormData,
|
||||
handleSaveHub,
|
||||
isEditMode
|
||||
}) {
|
||||
if (!showHubModal) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay active">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h2>{hubFormData.id ? 'Edit' : 'Tambah'} Rumah Ibadah</h2>
|
||||
<button className="close-btn" onClick={() => setShowHubModal(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleSaveHub} style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
||||
<div className="form-group">
|
||||
<label>Nama Rumah Ibadah</label>
|
||||
<input type="text" className="form-control" value={hubFormData.name} onChange={e => setHubFormData({...hubFormData, name: e.target.value})} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Tipe</label>
|
||||
<select className="form-control" value={hubFormData.type} onChange={e => setHubFormData({...hubFormData, type: e.target.value})}>
|
||||
<option value="Mosque">Masjid</option>
|
||||
<option value="Church">Gereja</option>
|
||||
<option value="Temple">Pura</option>
|
||||
<option value="Vihara">Vihara</option>
|
||||
<option value="Other">Lainnya</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Radius Jangkauan (Meter)</label>
|
||||
<input type="number" className="form-control" value={hubFormData.radius_meter} onChange={e => setHubFormData({...hubFormData, radius_meter: e.target.value})} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Nomor Kontak PIC</label>
|
||||
<input type="text" className="form-control" value={hubFormData.pic_phone || ''} onChange={e => setHubFormData({...hubFormData, pic_phone: e.target.value})} placeholder="Contoh: 08123456789" />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-group">
|
||||
<label>Latitude</label>
|
||||
<input type="text" className="form-control" value={hubFormData.lat} readOnly />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Longitude</label>
|
||||
<input type="text" className="form-control" value={hubFormData.lng} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }}>
|
||||
<i className="fas fa-save"></i> Simpan
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function PicListModal({
|
||||
showPicListModal,
|
||||
setShowPicListModal,
|
||||
myHub,
|
||||
usersList
|
||||
}) {
|
||||
if (!showPicListModal || !myHub) return null;
|
||||
|
||||
// Filter PICs assigned to this hub
|
||||
const assignedPics = usersList.filter(u => u.role === 'pic' && u.id === myHub.pic_id);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay active">
|
||||
<div className="modal-content" style={{ maxWidth: '500px', width: '90%' }}>
|
||||
<div className="modal-header">
|
||||
<h2>
|
||||
<i className="fas fa-id-card text-primary" style={{ marginRight: '8px' }}></i>
|
||||
Daftar PIC Lapangan
|
||||
</h2>
|
||||
<button className="close-btn" onClick={() => setShowPicListModal(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '15px', padding: '10px 0' }}>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
||||
Berikut adalah akun PIC / Surveyor Lapangan yang ditugaskan untuk mengelola dan menyurvei wilayah jangkauan <strong>{myHub.name}</strong>.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '10px' }}>
|
||||
{assignedPics.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px', background: '#f8fafc', borderRadius: '8px', color: '#94a3b8', border: '1px dashed #cbd5e1' }}>
|
||||
<i className="fas fa-user-slash" style={{ fontSize: '1.5rem', marginBottom: '8px', display: 'block' }}></i>
|
||||
Belum ada akun PIC yang ditugaskan ke Hub ini.
|
||||
</div>
|
||||
) : (
|
||||
assignedPics.map(pic => (
|
||||
<div key={pic.id} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '15px',
|
||||
padding: '15px',
|
||||
background: 'rgba(59, 130, 246, 0.05)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.15)',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '45px',
|
||||
height: '45px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.1rem'
|
||||
}}>
|
||||
{pic.full_name ? pic.full_name.charAt(0).toUpperCase() : 'P'}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '0.95rem', fontWeight: 'bold', color: 'var(--text-main)' }}>{pic.full_name}</h4>
|
||||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}><i className="fas fa-envelope" style={{ marginRight: '6px' }}></i>{pic.email}</p>
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 'bold',
|
||||
background: '#dbeafe',
|
||||
color: '#3b82f6',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
PIC Surveyor
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ProfileModal({
|
||||
showProfileModal,
|
||||
setShowProfileModal,
|
||||
profile,
|
||||
user,
|
||||
handleLogout,
|
||||
isLoggingOut
|
||||
}) {
|
||||
if (!showProfileModal) return null;
|
||||
|
||||
const getRoleBadge = (role) => {
|
||||
switch (role) {
|
||||
case 'super_admin':
|
||||
return {
|
||||
label: 'Super Admin',
|
||||
bg: '#fee2e2',
|
||||
color: '#ef4444',
|
||||
icon: 'fa-shield-halved'
|
||||
};
|
||||
case 'pic':
|
||||
return {
|
||||
label: 'Surveyor / PIC Lapangan',
|
||||
bg: '#dbeafe',
|
||||
color: '#3b82f6',
|
||||
icon: 'fa-route'
|
||||
};
|
||||
case 'hub_head':
|
||||
return {
|
||||
label: 'Ketua Pengurus Rumah Ibadah',
|
||||
bg: '#fef3c7',
|
||||
color: '#d97706',
|
||||
icon: 'fa-mosque'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Pengguna',
|
||||
bg: '#f1f5f9',
|
||||
color: '#475569',
|
||||
icon: 'fa-user'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const badge = getRoleBadge(profile?.role);
|
||||
const initials = profile?.full_name ? profile.full_name.substring(0, 2).toUpperCase() : 'US';
|
||||
|
||||
return (
|
||||
<div className="modal-overlay active" onClick={() => setShowProfileModal(false)}>
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{ maxWidth: '400px', width: '90%', textAlign: 'center', padding: '30px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header" style={{ marginBottom: '10px', justifyContent: 'center', position: 'relative' }}>
|
||||
<h2 style={{ fontSize: '1.3rem', margin: 0, fontWeight: '700' }}>Profil Pengguna</h2>
|
||||
<button
|
||||
className="close-btn"
|
||||
onClick={() => setShowProfileModal(false)}
|
||||
style={{ position: 'absolute', right: 0, top: '50%', transform: 'translateY(-50%)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '20px', padding: '10px 0 0 0' }}>
|
||||
{/* Avatar Area */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, var(--primary), #6366f1)',
|
||||
color: 'white',
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 10px 25px rgba(59, 130, 246, 0.4)',
|
||||
marginBottom: '5px'
|
||||
}}>
|
||||
{initials}
|
||||
</div>
|
||||
|
||||
{/* User Details */}
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 4px 0', fontSize: '1.25rem', fontWeight: '700', color: '#1e293b' }}>
|
||||
{profile?.full_name || 'Pengguna Sinergi Umat'}
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 12px 0', color: '#64748b', fontSize: '0.9rem' }}>
|
||||
{user?.email}
|
||||
</p>
|
||||
|
||||
{/* Role Badge */}
|
||||
<span style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: '700',
|
||||
background: badge.bg,
|
||||
color: badge.color,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
boxShadow: `0 2px 8px ${badge.bg}`
|
||||
}}>
|
||||
<i className={`fas ${badge.icon}`}></i> {badge.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr style={{ width: '100%', border: '0', borderTop: '1px solid #e2e8f0', margin: '5px 0' }} />
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={isLoggingOut}
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
fontWeight: '600',
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(239, 68, 68, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.transform = 'translateY(-2px)'}
|
||||
onMouseOut={(e) => e.currentTarget.style.transform = 'translateY(0)'}
|
||||
>
|
||||
<i className="fas fa-sign-out-alt"></i> {isLoggingOut ? 'Keluar...' : 'Keluar dari Akun (Logout)'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowProfileModal(false)}
|
||||
className="btn btn-outline"
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.05)',
|
||||
color: '#475569',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
fontWeight: '500',
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s'
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
|
||||
onMouseOut={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.05)'}
|
||||
>
|
||||
Kembali ke Dasbor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
|
||||
// Pure JavaScript GPS EXIF metadata reader
|
||||
function getEXIFGPS(arrayBuffer) {
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
if (dataView.getUint16(0, false) !== 0xFFD8) {
|
||||
return null; // Not a JPEG
|
||||
}
|
||||
let offset = 2;
|
||||
const length = dataView.byteLength;
|
||||
let exifOffset = null;
|
||||
|
||||
while (offset < length) {
|
||||
const marker = dataView.getUint16(offset, false);
|
||||
offset += 2;
|
||||
if (marker === 0xFFE1) { // APP1 segment containing EXIF
|
||||
const segmentLength = dataView.getUint16(offset, false);
|
||||
if (dataView.getUint32(offset + 2, false) === 0x45786966) { // "Exif"
|
||||
exifOffset = offset + 8;
|
||||
break;
|
||||
}
|
||||
offset += segmentLength;
|
||||
} else if ((marker & 0xFF00) === 0xFF00) {
|
||||
const segmentLength = dataView.getUint16(offset, false);
|
||||
offset += segmentLength;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!exifOffset) return null;
|
||||
|
||||
const littleEndian = dataView.getUint16(exifOffset, false) === 0x4949;
|
||||
const ifd0Offset = dataView.getUint32(exifOffset + 4, littleEndian);
|
||||
|
||||
function parseIFD(offset, tiffOffset) {
|
||||
try {
|
||||
const entriesCount = dataView.getUint16(tiffOffset + offset, littleEndian);
|
||||
const tags = {};
|
||||
|
||||
for (let i = 0; i < entriesCount; i++) {
|
||||
const entryOffset = tiffOffset + offset + 2 + i * 12;
|
||||
if (entryOffset + 12 > dataView.byteLength) break;
|
||||
const tag = dataView.getUint16(entryOffset, littleEndian);
|
||||
const type = dataView.getUint16(entryOffset + 2, littleEndian);
|
||||
const count = dataView.getUint32(entryOffset + 4, littleEndian);
|
||||
const valueOffset = dataView.getUint32(entryOffset + 8, littleEndian);
|
||||
|
||||
tags[tag] = { type, count, offset: valueOffset, rawOffset: entryOffset + 8 };
|
||||
}
|
||||
return tags;
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const ifd0 = parseIFD(ifd0Offset, exifOffset);
|
||||
if (ifd0[0x8825]) { // GPS IFD Pointer
|
||||
const gpsOffset = ifd0[0x8825].offset;
|
||||
const gpsIFD = parseIFD(gpsOffset, exifOffset);
|
||||
|
||||
const readRational = (valueOffset) => {
|
||||
const targetOffset = exifOffset + valueOffset;
|
||||
if (targetOffset + 8 > dataView.byteLength) return 0;
|
||||
const num = dataView.getUint32(targetOffset, littleEndian);
|
||||
const den = dataView.getUint32(targetOffset + 4, littleEndian);
|
||||
return den === 0 ? 0 : num / den;
|
||||
};
|
||||
|
||||
const readCoordinate = (tag) => {
|
||||
if (!gpsIFD[tag]) return null;
|
||||
const offset = gpsIFD[tag].offset;
|
||||
const deg = readRational(offset);
|
||||
const min = readRational(offset + 8);
|
||||
const sec = readRational(offset + 16);
|
||||
return deg + min / 60 + sec / 3600;
|
||||
};
|
||||
|
||||
let lat = readCoordinate(2); // GPSLatitude
|
||||
let lng = readCoordinate(4); // GPSLongitude
|
||||
|
||||
if (lat !== null && lng !== null) {
|
||||
if (gpsIFD[1]) { // GPSLatitudeRef
|
||||
const refOffset = gpsIFD[1].rawOffset;
|
||||
const refChar = String.fromCharCode(dataView.getUint8(refOffset));
|
||||
if (refChar === 'S') lat = -lat;
|
||||
}
|
||||
if (gpsIFD[3]) { // GPSLongitudeRef
|
||||
const refOffset = gpsIFD[3].rawOffset;
|
||||
const refChar = String.fromCharCode(dataView.getUint8(refOffset));
|
||||
if (refChar === 'W') lng = -lng;
|
||||
}
|
||||
return { lat, lng };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('GPS parsing error:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function SurveyMethodModal({
|
||||
showSurveyModal,
|
||||
setShowSurveyModal,
|
||||
setPlacementMode,
|
||||
setShowToast,
|
||||
hubs,
|
||||
mapInstance,
|
||||
setHhFormData,
|
||||
setImageFile,
|
||||
setShowHhModal,
|
||||
showToastNotification,
|
||||
profile
|
||||
}) {
|
||||
const [optionMode, setOptionMode] = useState(null); // 'upload' or null
|
||||
const [gpsData, setGpsData] = useState(null);
|
||||
const [errorMsg, setErrorMsg] = useState(null);
|
||||
const [tempFile, setTempFile] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
if (!showSurveyModal) return null;
|
||||
|
||||
const resetSurveyModal = () => {
|
||||
setOptionMode(null);
|
||||
setGpsData(null);
|
||||
setErrorMsg(null);
|
||||
setTempFile(null);
|
||||
};
|
||||
|
||||
const handleSelectMapOption = () => {
|
||||
setPlacementMode('household');
|
||||
setShowToast(true);
|
||||
setShowSurveyModal(false);
|
||||
resetSurveyModal();
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
setErrorMsg(null);
|
||||
setGpsData(null);
|
||||
setTempFile(null);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const buffer = event.target.result;
|
||||
const coords = getEXIFGPS(buffer);
|
||||
|
||||
if (coords) {
|
||||
setGpsData(coords);
|
||||
setTempFile(file);
|
||||
showToastNotification('Metadata lokasi GPS berhasil ditarik!');
|
||||
} else {
|
||||
setErrorMsg('Foto ini tidak memiliki metadata lokasi GPS (geotag). Silakan ambil foto baru menggunakan kamera ponsel dengan mengaktifkan GPS / Lokasi, atau gunakan opsi "Tentukan Posisi Melalui Peta".');
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setErrorMsg('Gagal membaca berkas gambar.');
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
};
|
||||
|
||||
const handleContinueSurvey = () => {
|
||||
if (!gpsData || !tempFile) return;
|
||||
|
||||
// Spatially calculate closest hub using Haversine / Leaflet map distance
|
||||
let closestHub = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
hubs.forEach(hub => {
|
||||
if (mapInstance && mapInstance.current) {
|
||||
const dist = mapInstance.current.distance([gpsData.lat, gpsData.lng], [hub.lat, hub.lng]);
|
||||
const hubRadius = parseInt(hub.radius) || 500;
|
||||
if (dist <= hubRadius && dist < minDistance) {
|
||||
minDistance = dist;
|
||||
closestHub = hub;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// For non-admin, if they use photo geotag, they can add anywhere.
|
||||
// If it's outside their own hub's radius, we assign it to their hub anyway but flag it with manual override.
|
||||
let finalAssignedHubId = closestHub ? closestHub.id : null;
|
||||
let forceManualOverride = false;
|
||||
|
||||
if (profile?.role !== 'super_admin') {
|
||||
const myHub = hubs.find(h => h.pic_id === profile?.id || h.head_id === profile?.id);
|
||||
if (myHub) {
|
||||
const isInsideRadius = closestHub && closestHub.id === myHub.id;
|
||||
if (!isInsideRadius) {
|
||||
// Outside their hub's radius! Force assignment to myHub and flag with manual override
|
||||
finalAssignedHubId = myHub.id;
|
||||
forceManualOverride = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setHhFormData({
|
||||
id: '',
|
||||
head_name: '',
|
||||
nik: '',
|
||||
no_kk: '',
|
||||
family_size: 1,
|
||||
status: 'Pending',
|
||||
photo_url: '',
|
||||
lat: gpsData.lat,
|
||||
lng: gpsData.lng,
|
||||
assigned_hub_id: finalAssignedHubId,
|
||||
manual_override_assigned_hub: forceManualOverride
|
||||
});
|
||||
|
||||
setImageFile(tempFile);
|
||||
setShowSurveyModal(false);
|
||||
setShowHhModal(true);
|
||||
resetSurveyModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay active" onClick={() => { setShowSurveyModal(false); resetSurveyModal(); }}>
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{ maxWidth: '500px', width: '90%', padding: '24px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h2><i className="fas fa-house-medical text-primary"></i> Pendataan KK Baru</h2>
|
||||
<button className="close-btn" onClick={() => { setShowSurveyModal(false); resetSurveyModal(); }}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
|
||||
{optionMode === null ? (
|
||||
<>
|
||||
<p style={{ margin: '0 0 10px 0', color: '#64748b', fontSize: '0.95rem', lineHeight: '1.5' }}>
|
||||
Silakan pilih salah satu opsi di bawah untuk menentukan lokasi koordinat rumah warga yang didata:
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{/* Option 1: Map click */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectMapOption}
|
||||
style={{
|
||||
background: 'rgba(59, 130, 246, 0.08)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.25)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.2s',
|
||||
width: '100%',
|
||||
color: '#1e293b'
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.08)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--primary, #3b82f6)',
|
||||
color: 'white',
|
||||
width: '40px', height: '40px', borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '1.25rem', flexShrink: 0
|
||||
}}>
|
||||
<i className="fas fa-map-location-dot"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '0.95rem', fontWeight: '700' }}>Tentukan Posisi Melalui Peta</h4>
|
||||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}>Pilih lokasi rumah warga secara manual dengan menunjuknya langsung di peta interaktif.</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Option 2: Geotagging upload */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptionMode('upload')}
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.08)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.25)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.2s',
|
||||
width: '100%',
|
||||
color: '#1e293b'
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.15)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.08)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--success, #10b981)',
|
||||
color: 'white',
|
||||
width: '40px', height: '40px', borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '1.25rem', flexShrink: 0
|
||||
}}>
|
||||
<i className="fas fa-camera-retro"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '0.95rem', fontWeight: '700' }}>Unggah Foto dengan Geotag</h4>
|
||||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}>Ekstraksi koordinat otomatis dari metadata GPS foto rumah warga yang diambil langsung di lapangan.</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptionMode(null)}
|
||||
style={{ background: 'none', border: 'none', color: '#64748b', cursor: 'pointer', fontSize: '0.9rem' }}
|
||||
>
|
||||
<i className="fas fa-arrow-left"></i> Kembali
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h4 style={{ fontSize: '0.95rem', fontWeight: '600', margin: 0 }}>Metode Unggah Foto Ber-Geotag</h4>
|
||||
<p style={{ margin: 0, color: '#64748b', fontSize: '0.85rem', lineHeight: '1.4' }}>
|
||||
Unggah berkas foto JPG/JPEG rumah warga yang diambil dari kamera ber-GPS aktif. Sistem akan menarik metadata lokasi secara otomatis.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: '12px',
|
||||
padding: '24px 16px',
|
||||
textAlign: 'center',
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.2s'
|
||||
}}
|
||||
onClick={() => fileInputRef.current && fileInputRef.current.click()}
|
||||
onMouseOver={(e) => e.currentTarget.style.borderColor = 'var(--success, #10b981)'}
|
||||
onMouseOut={(e) => e.currentTarget.style.borderColor = '#cbd5e1'}
|
||||
>
|
||||
<i className="fas fa-cloud-arrow-up" style={{ fontSize: '2.5rem', color: '#94a3b8', marginBottom: '10px', display: 'block' }}></i>
|
||||
<span style={{ fontSize: '0.9rem', color: '#475569', fontWeight: '500' }}>
|
||||
{tempFile ? tempFile.name : 'Klik untuk Pilih Foto Rumah'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept="image/jpeg,image/jpg"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div style={{ background: '#fee2e2', color: '#ef4444', padding: '12px', borderRadius: '8px', fontSize: '0.8rem', lineHeight: '1.4', fontWeight: '500' }}>
|
||||
<i className="fas fa-exclamation-triangle" style={{ marginRight: '6px' }}></i> {errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gpsData && (
|
||||
<div style={{ background: '#d1fae5', color: '#065f46', padding: '12px', borderRadius: '8px', fontSize: '0.85rem', fontWeight: '500' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
|
||||
<i className="fas fa-circle-check" style={{ color: '#10b981' }}></i>
|
||||
<strong>Metadata Lokasi Terbaca!</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', fontFamily: 'monospace', marginTop: '4px', opacity: 0.9 }}>
|
||||
Lintang: {gpsData.lat.toFixed(6)} | Bujur: {gpsData.lng.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinueSurvey}
|
||||
disabled={!gpsData}
|
||||
className="btn btn-success"
|
||||
style={{
|
||||
flex: 1, padding: '12px', borderRadius: '10px',
|
||||
fontWeight: '600', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px',
|
||||
opacity: gpsData ? 1 : 0.5, cursor: gpsData ? 'pointer' : 'not-allowed'
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-arrow-right"></i> Lanjutkan Pengisian Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function UserProvisioningModal({
|
||||
showUserModal,
|
||||
setShowUserModal,
|
||||
usersList,
|
||||
profile,
|
||||
userFormData,
|
||||
setUserFormData,
|
||||
handleCreateUser,
|
||||
isSubmittingUser,
|
||||
handleDeleteUser,
|
||||
hubs
|
||||
}) {
|
||||
if (!showUserModal) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay active">
|
||||
<div className="modal-content" style={{ maxWidth: '900px', width: '90%' }}>
|
||||
<div className="modal-header">
|
||||
<h2><i className="fas fa-users-cog text-primary"></i> Manajemen Pengguna</h2>
|
||||
<button className="close-btn" onClick={() => setShowUserModal(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '20px', maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
|
||||
{/* Form Tambah Pengguna */}
|
||||
<div style={{ background: '#f8fafc', padding: '20px', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
||||
<h3 style={{ margin: '0 0 15px 0', fontSize: '1.1rem' }}>Tambah Pengguna Baru</h3>
|
||||
<form onSubmit={handleCreateUser} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '15px' }}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" className="form-control" value={userFormData.email} onChange={(e) => setUserFormData({...userFormData, email: e.target.value})} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Kata Sandi Sementara</label>
|
||||
<input type="text" className="form-control" value={userFormData.password} onChange={(e) => setUserFormData({...userFormData, password: e.target.value})} required minLength="6" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Nama Lengkap</label>
|
||||
<input type="text" className="form-control" value={userFormData.full_name} onChange={(e) => setUserFormData({...userFormData, full_name: e.target.value})} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Peran (Role)</label>
|
||||
<select className="form-control" value={userFormData.role} onChange={(e) => setUserFormData({...userFormData, role: e.target.value, hub_id: ''})} required>
|
||||
<option value="pic">Surveyor / PIC Lapangan</option>
|
||||
<option value="hub_head">Ketua Pengurus Rumah Ibadah</option>
|
||||
<option value="super_admin">Super Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
{(userFormData.role === 'pic' || userFormData.role === 'hub_head') && (
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Tugaskan ke Rumah Ibadah (Hub)</label>
|
||||
<select className="form-control" value={userFormData.hub_id || ''} onChange={(e) => setUserFormData({...userFormData, hub_id: e.target.value})} required>
|
||||
<option value="">Pilih Rumah Ibadah</option>
|
||||
{hubs.map(h => (
|
||||
<option key={h.id} value={h.id}>{h.name} ({h.type === 'Mosque' ? 'Masjid' : h.type === 'Church' ? 'Gereja' : h.type === 'Temple' ? 'Pura' : h.type === 'Vihara' ? 'Vihara' : 'Lainnya'})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'flex-end', marginTop: '10px' }}>
|
||||
<button type="submit" className="btn btn-primary" disabled={isSubmittingUser}>
|
||||
<i className="fas fa-plus"></i> {isSubmittingUser ? 'Menyimpan...' : 'Buat Akun'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Tabel Pengguna */}
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 15px 0', fontSize: '1.1rem' }}>Daftar Pengguna</h3>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
|
||||
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Nama</th>
|
||||
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Email</th>
|
||||
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0' }}>Role</th>
|
||||
<th style={{ padding: '10px', borderBottom: '2px solid #e2e8f0', textAlign: 'right' }}>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usersList.length === 0 ? (
|
||||
<tr><td colSpan="4" style={{ textAlign: 'center', padding: '20px', color: '#64748b' }}>Memuat data pengguna...</td></tr>
|
||||
) : (
|
||||
usersList.map(u => (
|
||||
<tr key={u.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
<td style={{ padding: '10px', fontWeight: '500' }}>{u.full_name}</td>
|
||||
<td style={{ padding: '10px', color: '#475569' }}>{u.email}</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<span style={{
|
||||
padding: '4px 8px', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 'bold',
|
||||
background: u.role === 'super_admin' ? '#fee2e2' : u.role === 'pic' ? '#dbeafe' : '#fef3c7',
|
||||
color: u.role === 'super_admin' ? '#ef4444' : u.role === 'pic' ? '#3b82f6' : '#d97706'
|
||||
}}>
|
||||
{u.role.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px', textAlign: 'right' }}>
|
||||
{u.id !== profile?.id && (
|
||||
<button onClick={() => handleDeleteUser(u.id)} className="btn btn-outline" style={{ padding: '4px 8px', fontSize: '0.8rem', color: '#ef4444', borderColor: '#ef4444' }}>
|
||||
<i className="fas fa-trash"></i> Hapus
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user