// cluster.jsx — Cluster hub (Izin Renovasi + Cluster OS stubs). Exported to window.
const PERMIT_STATUS = {
diajukan: { label: 'DIAJUKAN', color: 'var(--s-500)', soft: 'var(--s-100)' },
ditinjau: { label: 'DITINJAU', color: 'var(--blue)', soft: 'var(--blue-50)' },
revisi: { label: 'REVISI', color: 'var(--amber)', soft: 'var(--amber-50)' },
disetujui: { label: 'DISETUJUI', color: 'var(--lime)', soft: 'var(--lime-50)' },
ditolak: { label: 'DITOLAK', color: 'var(--coral)', soft: 'var(--coral-50)' },
selesai: { label: 'SELESAI', color: 'var(--violet)', soft: 'var(--violet-50)' },
};
function PermitStatusChip({ status }) {
const s = PERMIT_STATUS[status] || PERMIT_STATUS.diajukan;
return (
{s.label}
);
}
const RENO_TYPES = [
['ringan', 'Ringan', 'Cat, perbaikan kecil'],
['sedang', 'Sedang', 'Tambah / ubah ruang'],
['berat', 'Berat', 'Struktur, tambah lantai'],
['bongkar_bangun', 'Bongkar Bangun', 'Bongkar total & bangun ulang'],
];
function fmtDate(d) { if (!d) return '-'; try { return new Date(d).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' }); } catch (e) { return d; } }
function fmtRp(n) { if (n == null) return '-'; return 'Rp' + Number(n).toLocaleString('id-ID'); }
const fieldStyle = { width: '100%', padding: '13px 14px', borderRadius: 'var(--r-md)', border: '1px solid var(--border)',
background: 'var(--surface-2)', fontFamily: 'var(--font)', fontSize: 14.5, fontWeight: 500, color: 'var(--text-1)', outline: 'none' };
function Label({ children, opt }) {
return ;
}
function PermitFlow({ clusters = [], profile, onClose, onDone }) {
const [clusterId, setClusterId] = React.useState(profile && profile.home_cluster_id ? profile.home_cluster_id : '');
const [unit, setUnit] = React.useState('');
const [addr, setAddr] = React.useState('');
const [renoType, setRenoType] = React.useState('');
const [startD, setStartD] = React.useState('');
const [endD, setEndD] = React.useState('');
const [cName, setCName] = React.useState('');
const [cPhone, setCPhone] = React.useState('');
const [workers, setWorkers] = React.useState('');
const [desc, setDesc] = React.useState('');
const [files, setFiles] = React.useState([]);
const [err, setErr] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [sent, setSent] = React.useState(false);
const fileRef = React.useRef(null);
const onPick = (e) => {
const list = Array.from(e.target.files || []);
setFiles(f => f.concat(list));
if (fileRef.current) fileRef.current.value = '';
};
const removeFile = (i) => setFiles(f => f.filter((_, j) => j !== i));
const submit = async () => {
setErr('');
if (!clusterId) return setErr('Pilih cluster dulu');
if (!renoType) return setErr('Pilih jenis renovasi');
if (!startD) return setErr('Isi tanggal mulai');
setBusy(true);
const res = await gsData.submitPermit({
cluster_id: clusterId, unit_number: unit.trim() || null, unit_address: addr.trim() || null,
reno_type: renoType, description: desc.trim() || null, contractor_name: cName.trim() || null,
contractor_phone: cPhone.trim() || null, worker_count: workers ? parseInt(workers, 10) : null,
start_date: startD || null, end_date: endD || null, files,
});
setBusy(false);
if (res.error) return setErr('Gagal mengirim: ' + res.error);
setSent(true); setTimeout(onDone, 1500);
};
if (sent) {
return (
Pengajuan terkirim!
Estate manager akan meninjau pengajuan renovasimu. Pantau statusnya di halaman Cluster.
);
}
return (
setAddr(e.target.value)} placeholder="Jalan / blok" style={fieldStyle} />
{RENO_TYPES.map(([k, lbl, sub]) => {
const on = renoType === k;
return (
);
})}
setCPhone(e.target.value)} placeholder="08xxxx" style={{ ...fieldStyle, fontFamily: 'var(--mono)' }} />
{files.length > 0 && (
{files.map((f, i) => (
{f.name}
))}
)}
{err && (
{err}
)}
);
}
function PermitDetailSheet({ permit, onClose }) {
const approved = permit.status === 'disetujui' || permit.status === 'selesai';
return (
{permit.reno_label}
{permit.cluster_name}{permit.unit_number ? ' · ' + permit.unit_number : ''}
{approved && permit.permit_number && (
SURAT IZIN RENOVASI
{permit.permit_number}
Berlaku {fmtDate(permit.start_date)} – {fmtDate(permit.end_date)}{permit.fee_amount ? ' · Biaya ' + fmtRp(permit.fee_amount) : ''}
)}
{(permit.status === 'revisi' || permit.status === 'ditolak') && permit.review_note && (
Catatan estate manager
{permit.review_note}
)}
{[['Tanggal', fmtDate(permit.start_date) + ' – ' + fmtDate(permit.end_date)],
['Kontraktor', permit.contractor_name || '-'],
['No. HP', permit.contractor_phone || '-'],
['Pekerja', permit.worker_count != null ? String(permit.worker_count) + ' orang' : '-'],
['Dokumen', permit.doc_count + ' file'],
['Diajukan', permit.time]].map(([k, v]) => (
{k}
{v}
))}
{permit.description &&
{permit.description}
}
);
}
const MEMBER_STATUS = {
pending: { label: 'MENUNGGU VERIFIKASI', color: 'var(--amber-700)', soft: 'var(--amber-50)' },
verified: { label: 'TERVERIFIKASI', color: 'var(--lime-700)', soft: 'var(--lime-50)' },
rejected: { label: 'DITOLAK', color: 'var(--coral-700)', soft: 'var(--coral-50)' },
};
function MemberStatusChip({ status }) {
const s = MEMBER_STATUS[status] || MEMBER_STATUS.pending;
return {s.label};
}
const IPKL_STATUS = {
unpaid: { label: 'BELUM BAYAR', color: 'var(--coral-700)', soft: 'var(--coral-50)' },
paid: { label: 'LUNAS', color: 'var(--lime-700)', soft: 'var(--lime-50)' },
overdue: { label: 'TERLAMBAT', color: 'var(--coral-700)', soft: 'var(--coral-50)' },
};
function IpklStatusChip({ status }) {
const s = IPKL_STATUS[status] || IPKL_STATUS.unpaid;
return {s.label};
}
function MembershipFlow({ clusters = [], profile, onClose, onDone }) {
const [clusterId, setClusterId] = React.useState(profile && profile.home_cluster_id ? profile.home_cluster_id : '');
const [unit, setUnit] = React.useState('');
const [rt, setRt] = React.useState('');
const [rw, setRw] = React.useState('');
const [err, setErr] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [sent, setSent] = React.useState(false);
const submit = async () => {
setErr('');
if (!clusterId) return setErr('Pilih cluster dulu');
if (!unit.trim()) return setErr('Isi nomor unit / rumah');
setBusy(true);
const res = await gsData.submitMembership({ cluster_id: clusterId, unit: unit.trim(), rt: rt.trim(), rw: rw.trim() });
setBusy(false);
if (res.error) { if (/duplicate|unique/i.test(res.error)) return setErr('Kamu sudah pernah mengajukan untuk cluster ini.'); return setErr('Gagal: ' + res.error); }
setSent(true); setTimeout(onDone, 1400);
};
if (sent) {
return (
Pengajuan terkirim!
Pengurus akan memverifikasi keanggotaanmu. Pantau statusnya di halaman Cluster.
);
}
return (
Daftarkan unitmu untuk jadi warga terverifikasi cluster. Pengurus akan meninjau & menyetujui.
setUnit(e.target.value)} placeholder="cth. C12 No. 7" style={fieldStyle} />
{err &&
{err}
}
);
}
function ClusterScreen({ authed, onRequireAuth, clusters = [], profile }) {
const [permits, setPermits] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [flow, setFlow] = React.useState(false);
const [detail, setDetail] = React.useState(null);
const [anns, setAnns] = React.useState([]);
const [memberships, setMemberships] = React.useState([]);
const [ipkl, setIpkl] = React.useState([]);
const [mFlow, setMFlow] = React.useState(false);
React.useEffect(() => { gsData.loadAnnouncements().then(setAnns); }, []);
const load = React.useCallback(() => {
if (!authed) { setPermits([]); setMemberships([]); setIpkl([]); setLoading(false); return; }
setLoading(true);
gsData.loadMyPermits().then(p => { setPermits(p); setLoading(false); });
gsData.loadMyMembership().then(setMemberships);
gsData.loadMyIpkl().then(setIpkl);
}, [authed]);
React.useEffect(() => { load(); }, [load]);
const myCluster = clusters.find(c => profile && c.id === profile.home_cluster_id);
const startPermit = () => { if (!authed) return onRequireAuth(); setFlow(true); };
const startMembership = () => { if (!authed) return onRequireAuth(); setMFlow(true); };
const hasPending = memberships.some(m => m.status === 'pending');
return (
Cluster
CLUSTER SAYA
{myCluster ? myCluster.name : 'Gading Serpong'}
{/* Izin Renovasi */}
Izin Renovasi
Pengajuan saya
{!authed ? (
Masuk untuk mengajukan & memantau izin renovasi.
) : loading ? (
Memuat...
) : permits.length === 0 ? (
Belum ada pengajuan.
) : (
{permits.map(p => (
))}
)}
{/* Pengumuman */}
{anns.length > 0 && (
Pengumuman
{anns.map(a => (
{a.pinned && }
{a.title}
{a.body &&
{a.body}
}
{a.cluster || 'Semua'}
{a.time}
))}
)}
{/* Keanggotaan warga */}
Keanggotaan warga
{!authed ? (
Masuk untuk mendaftar sebagai warga cluster.
) : memberships.length === 0 ? (
) : (
{memberships.map(m => (
{m.cluster_name}
Unit {m.unit}{m.rt || m.rw ? ' · RT ' + (m.rt || '-') + ' / RW ' + (m.rw || '-') : ''}
))}
{!hasPending && (
)}
)}
{/* Tagihan IPKL */}
Tagihan IPKL
{!authed ? (
Masuk untuk melihat tagihan IPKL.
) : ipkl.length === 0 ? (
Belum ada tagihan.
) : (
{ipkl.map(b => (
{fmtRp(b.amount)}
{b.period}{b.cluster ? ' · ' + b.cluster : ''}{b.due ? ' · jatuh tempo ' + fmtDate(b.due) : ''}
))}
)}
Pembayaran langsung ke pengurus/pengelola. Aplikasi hanya membantu memantau status tagihan.
{flow && (
setFlow(false)} onDone={() => { setFlow(false); load(); }} />
)}
{mFlow && (
setMFlow(false)} onDone={() => { setMFlow(false); load(); }} />
)}
{detail &&
setDetail(null)} />}
);
}
Object.assign(window, { ClusterScreen, PermitFlow, PermitDetailSheet, PermitStatusChip, MembershipFlow });