(() => {
const { Card, Button, IconButton, Badge, Modal, Field, Input, Select, Textarea, DataTable, StatCard, EmptyState, Chip, Icon, Avatar } = window.TaskFlowDesignSystem_dd53c2;
/* Absen kunjungan perusahaan — khusus role Marketing.
GPS WAJIB aktif: modal langsung meminta izin lokasi perangkat (PC / HP / tablet)
dan tombol simpan baru terbuka setelah koordinat benar-benar didapat.
Di produksi koordinat dikirim ke server bersama waktu server (bukan waktu perangkat),
dan server menolak absen tanpa lat/lng — lihat CODEIGNITER3.md. */
const jamNow = () => {
const d = new Date();
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
const GPS_HELP = [
{ icon: "monitor", label: "PC / Laptop", text: "Klik ikon kunci di address bar → Location → Allow, lalu muat ulang halaman." },
{ icon: "smartphone", label: "Android", text: "Aktifkan Lokasi di panel notifikasi, lalu izinkan akses lokasi untuk browser." },
{ icon: "tablet", label: "iPhone / iPad", text: "Setelan → Privasi & Keamanan → Layanan Lokasi → aktifkan untuk browser." },
];
function GpsBadge({ geo }) {
const map = {
idle: { tone: "neutral", icon: "satellite-dish", text: "GPS belum dicek" },
locating: { tone: "gold", icon: "loader", text: "Mencari lokasi…" },
ok: { tone: "leaf", icon: "map-pin-check", text: `GPS aktif · ±${Math.round(geo.acc || 0)} m` },
denied: { tone: "coral", icon: "map-pin-off", text: "Izin lokasi ditolak" },
unsupported: { tone: "coral", icon: "map-pin-off", text: "Perangkat tanpa GPS" },
insecure: { tone: "coral", icon: "shield-alert", text: "Butuh HTTPS" },
timeout: { tone: "coral", icon: "map-pin-off", text: "Sinyal GPS tidak terbaca" },
error: { tone: "coral", icon: "map-pin-off", text: "Lokasi gagal dibaca" },
};
const s = map[geo.state] || map.idle;
const fg = { leaf: "var(--leaf-600)", gold: "var(--gold-600)", coral: "var(--coral-600)", neutral: "var(--neutral-600)" }[s.tone];
return {s.text} ;
}
function AbsenModal({ open, onClose, onSave, companies }) {
const [geo, setGeo] = React.useState({ state: "idle" });
const [form, setForm] = React.useState({ company: "", purpose: VISIT_PURPOSES[0], contact: "", note: "" });
const [err, setErr] = React.useState("");
const [clock, setClock] = React.useState(jamNow());
const bp = useBreakpoint();
const narrow = bp === "mobile";
const [blocked, setBlocked] = React.useState(false);
const ask = React.useCallback(() => {
if (!navigator.geolocation) return setGeo({ state: "unsupported" });
// Geolocation hanya jalan di HTTPS (atau localhost) — di HTTP browser diam saja.
if (!window.isSecureContext) return setGeo({ state: "insecure" });
setBlocked(false);
setGeo({ state: "locating" });
// PENTING: permintaan lokasi harus dipanggil LANGSUNG (sinkron) di dalam gesture
// pengguna. Menunggu `await navigator.permissions.query` lebih dulu membuat
// Safari/iOS kehilangan user-activation sehingga popup izin tidak pernah muncul.
navigator.geolocation.getCurrentPosition(
(p) => setGeo({ state: "ok", lat: p.coords.latitude, lng: p.coords.longitude, acc: p.coords.accuracy }),
(e) => { if (e.code === 1) setBlocked(true); setGeo({ state: e.code === 1 ? "denied" : e.code === 3 ? "timeout" : "error", msg: e.message }); },
{ enableHighAccuracy: true, timeout: 20000, maximumAge: 0 },
);
// Status izin dibaca setelahnya — hanya untuk memilih pesan bantuan.
try {
if (navigator.permissions) navigator.permissions.query({ name: "geolocation" })
.then((p) => { if (p.state === "denied") setBlocked(true); }).catch(() => {});
} catch (e) {}
}, []);
const framed = typeof window !== "undefined" && window.self !== window.top;
// Popup izin GPS muncul begitu modal dibuka — absen tidak bisa dilanjutkan tanpa lokasi.
React.useEffect(() => { if (open) { setForm({ company: "", purpose: VISIT_PURPOSES[0], contact: "", note: "" }); setErr(""); ask(); } }, [open, ask]);
React.useEffect(() => {
if (!open) return;
const t = setInterval(() => setClock(jamNow()), 1000);
return () => clearInterval(t);
}, [open]);
if (!open) return null;
const ready = geo.state === "ok";
const failed = ["denied", "unsupported", "timeout", "error", "insecure"].includes(geo.state);
const save = () => {
if (!ready) return setErr("Lokasi GPS belum didapat. Aktifkan GPS lalu coba lagi.");
if (!form.company.trim()) return setErr("Nama perusahaan wajib diisi.");
onSave({ company: form.company.trim(), purpose: form.purpose, contact: form.contact.trim(), note: form.note.trim(), lat: geo.lat, lng: geo.lng, acc: Math.round(geo.acc || 0), time: jamNow() });
};
return (
Batal
Clock In Sekarang >}>
{TODAY} · {clock}
Waktu clock in memakai jam server saat disimpan.
{geo.state === "locating" && (
Izinkan akses lokasi pada popup browser untuk melanjutkan.
)}
{geo.state === "locating" && (
Popup tidak muncul? Minta Izin Lokasi
)}
{ready && (
{[["Latitude", fmtCoord(geo.lat)], ["Longitude", fmtCoord(geo.lng)], ["Akurasi", `±${Math.round(geo.acc || 0)} meter`], ["Sumber", "GPS perangkat"]].map(([k, v]) => (
{k}
{v}
))}
Lihat titik absen di peta
)}
{failed && (
GPS wajib aktif untuk absen
{geo.state === "insecure" && (
Halaman ini dibuka tanpa HTTPS. Browser memblokir GPS di koneksi tidak aman — buka aplikasi lewat alamat https:// lalu coba lagi.
)}
{framed && (
Halaman sedang dibuka di dalam bingkai (iframe/webview). Buka aplikasi langsung di tab browser agar izin lokasi bisa diminta.
)}
{blocked && (
Izin lokasi sudah diblokir untuk situs ini, sehingga popup tidak muncul lagi. Buka setelan izin situs (ikon 🔒 / ⓘ di sebelah alamat → Lokasi → Izinkan ), lalu tekan Muat Ulang Halaman.
)}
{GPS_HELP.map((h) => (
{h.label} — {h.text}
))}
Coba Aktifkan GPS Lagi
{blocked && window.location.reload()}>Muat Ulang Halaman }
)}
{ setForm({ ...form, company: e.target.value }); setErr(""); }} />
setForm({ ...form, purpose: e.target.value })} options={VISIT_PURPOSES} />
{companies.map((c) => )}
setForm({ ...form, contact: e.target.value })} />
{err &&
{err}
}
);
}
function AttendanceScreen({ records = [], meId, readOnly = false, onSave, onDelete, companies = COMPANIES }) {
const bp = useBreakpoint();
const narrow = bp === "mobile";
const [open, setOpen] = React.useState(false);
const [who, setWho] = React.useState("all");
const [q, setQ] = React.useState("");
const [page, setPage] = React.useState(1);
const [removing, setRemoving] = React.useState(null);
// Default halaman ini = minggu saat login (Senin–Minggu), bukan seluruh riwayat.
const week = React.useMemo(() => weekRange(), []);
const month = React.useMemo(() => monthRange(), []);
const [rg, setRg] = React.useState(week);
const preset = rg.from === week.from && rg.to === week.to ? "week"
: rg.from === month.from && rg.to === month.to ? "month" : "custom";
const PER = 15;
const marketers = React.useMemo(() => USERS.filter((u) => (u.access || "user") === "marketing"), []);
const scoped = React.useMemo(() => {
const s = q.trim().toLowerCase();
return records
.filter((r) => (readOnly ? who === "all" || r.user === who : r.user === meId))
.filter((r) => inRange(r.date, rg.from, rg.to))
.filter((r) => !s || r.company.toLowerCase().includes(s) || String(r.purpose).toLowerCase().includes(s))
.slice()
.sort((a, b) => (parseId(b.date) - parseId(a.date)) || String(b.time).localeCompare(String(a.time)));
}, [records, who, q, readOnly, meId, rg]);
React.useEffect(() => { setPage(1); }, [who, q, rg]);
const shown = scoped.slice(0, page * PER);
const today = scoped.filter((r) => r.date === TODAY);
const days = new Set(scoped.map((r) => r.date)).size;
const firms = new Set(scoped.map((r) => r.company)).size;
const exportCsv = () => {
const head = ["Tanggal", "Jam", "Marketing", "Perusahaan", "Tujuan", "Ditemui", "Latitude", "Longitude", "Akurasi (m)", "Catatan"];
const rows = scoped.map((r) => [r.date, r.time, nameOf(r.user), r.company, r.purpose, r.contact || "", fmtCoord(r.lat), fmtCoord(r.lng), r.acc, (r.note || "").replace(/[\r\n]+/g, " ")]);
const csv = [head, ...rows].map((line) => line.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\r\n");
const url = URL.createObjectURL(new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }));
const a = document.createElement("a");
a.href = url; a.download = `absen-kunjungan-${TODAY.replace(/\s/g, "-").toLowerCase()}.csv`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
};
// Export PDF mengikuti rentang tanggal yang sedang dipilih (cetak lewat iframe, tanpa pustaka tambahan).
const exportPdf = () => {
const pretty = (iso) => { const [y, m, d] = String(iso).split("-"); return `${d} ${MONTHS[+m - 1]} ${y}`; };
const rows = scoped.map((r) => `
${r.date} ${r.time} WITA
${readOnly ? `${nameOf(r.user)} ` : ""}
${r.company} ${r.purpose}${r.contact ? " · " + r.contact : ""}
${r.note ? `${r.note}
` : ""}
${fmtCoord(r.lat)}, ${fmtCoord(r.lng)}±${r.acc} m
`).join("");
const kpi = (l, v) => ``;
const doc = `Absen Kunjungan ${rg.from} - ${rg.to}
Laporan Absen Kunjungan
${readOnly ? "Semua marketing" : nameOf(meId)}${readOnly && who !== "all" ? " · " + nameOf(who) : ""} · periode ${pretty(rg.from)} s/d ${pretty(rg.to)} · dicetak ${TODAY}
${kpi("Total kunjungan", scoped.length)}${kpi("Hari aktif", days)}${kpi("Perusahaan", firms)}${kpi("Akurasi ≤ 25 m", scoped.filter((r) => r.acc <= 25).length)}
Tanggal & Jam ${readOnly ? "Marketing " : ""}Perusahaan & Tujuan Koordinat GPS
${rows || `Tidak ada absen pada rentang ini. `}
Koordinat diambil dari GPS perangkat saat clock in dan tidak dapat diubah oleh user.
`;
const frame = document.createElement("iframe");
frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0";
document.body.appendChild(frame);
frame.srcdoc = doc;
frame.onload = () => setTimeout(() => { frame.contentWindow.focus(); frame.contentWindow.print(); setTimeout(() => frame.remove(), 1500); }, 500);
};
return (
{!readOnly && (
Absen Kunjungan Perusahaan
{TODAY} · {today.length ? `${today.length} kunjungan tercatat hari ini` : "belum ada absen hari ini"}. GPS perangkat wajib aktif saat clock in.
setOpen(true)}>Absen Sekarang
)}
Riwayat Absen
setQ(e.target.value)} />
Export PDF
setRg(week)} />
setRg(month)} />
e.target.value && setRg({ ...rg, from: e.target.value })} />
s/d
e.target.value && setRg({ ...rg, to: e.target.value })} />
{readOnly && marketers.length > 1 && (
setWho("all")} />
{marketers.map((u) => (
r.user === u.id).length}
active={who === u.id} onClick={() => setWho(u.id)} />
))}
)}
{scoped.length === 0
?
: (
<>
({
when: (
{r.date}
{r.time} WITA
),
...(readOnly ? {
who: (
{nameOf(r.user)}
),
} : {}),
firm: (
{r.company}
{r.purpose}
{r.contact && {r.contact} }
{r.note && {r.note} }
),
loc: (
{fmtCoord(r.lat)}, {fmtCoord(r.lng)}
±{r.acc} m
Peta
),
...(readOnly && onDelete ? { act: setRemoving(r)} /> } : {}),
}))}
/>
{shown.length < scoped.length && (
setPage((p) => p + 1)}>
Muat {Math.min(PER, scoped.length - shown.length)} absen lagi
)}
>
)}
{!readOnly && (
Absen tidak dapat diubah atau dihapus setelah tersimpan. Koordinat, akurasi, dan jam tercatat di Log Activity dan terlihat oleh Direktur.
)}
{removing &&
setRemoving(null)}
footer={<> setRemoving(null)}>Batal
{ onDelete(removing); setRemoving(null); }}>Hapus Absen >} />}
setOpen(false)}
onSave={(payload) => { onSave && onSave(payload); setOpen(false); }} />
);
}
Object.assign(window, { AttendanceScreen });
})();