707 lines
24 KiB
TypeScript
707 lines
24 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
||
import {
|
||
ClipboardCheck,
|
||
Plus,
|
||
Search,
|
||
Download,
|
||
Link2,
|
||
Pencil,
|
||
Trash2,
|
||
ChevronLeft,
|
||
UserCheck,
|
||
Copy,
|
||
RefreshCw,
|
||
X,
|
||
} from "lucide-react";
|
||
import { useAppNav } from "../../contexts/AppNavContext";
|
||
import { usePermission } from "../../contexts/PermissionContext";
|
||
import { attendancesApi } from "../../../services/api";
|
||
import type {
|
||
AttendanceRecord,
|
||
AttendanceSearchParticipant,
|
||
AttendanceCheckInRow,
|
||
} from "../../../services/api/types";
|
||
|
||
function publicAttendanceUrl(token: string) {
|
||
return `${window.location.origin}/attendance/${token}`;
|
||
}
|
||
|
||
function formatDt(iso?: string | null) {
|
||
if (!iso) return "—";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "—";
|
||
return d.toLocaleString(undefined, {
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
interface FormState {
|
||
name: string;
|
||
description: string;
|
||
allowDuplicateCheckIn: boolean;
|
||
publicLinkEnabled: boolean;
|
||
}
|
||
|
||
const emptyForm: FormState = {
|
||
name: "",
|
||
description: "",
|
||
allowDuplicateCheckIn: false,
|
||
publicLinkEnabled: true,
|
||
};
|
||
|
||
export function AttendancePage() {
|
||
const { selectedEventId } = useAppNav();
|
||
const { canModule } = usePermission();
|
||
const canReadAttendance = canModule("attendance", "read");
|
||
const canWriteAttendance = canModule("attendance", "write");
|
||
const canDeleteAttendance = canModule("attendance", "delete");
|
||
const [sessions, setSessions] = useState<AttendanceRecord[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
const [panelOpen, setPanelOpen] = useState(false);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [form, setForm] = useState<FormState>(emptyForm);
|
||
const [saving, setSaving] = useState(false);
|
||
const [copyMsg, setCopyMsg] = useState<string | null>(null);
|
||
|
||
const [searchQ, setSearchQ] = useState("");
|
||
const [searchResults, setSearchResults] = useState<AttendanceSearchParticipant[]>([]);
|
||
const [searching, setSearching] = useState(false);
|
||
const [checkInBusy, setCheckInBusy] = useState<string | null>(null);
|
||
|
||
const [checkIns, setCheckIns] = useState<AttendanceCheckInRow[]>([]);
|
||
const [checkInsTotal, setCheckInsTotal] = useState(0);
|
||
const [checkInsLoading, setCheckInsLoading] = useState(false);
|
||
|
||
const selected = sessions.find((s) => s._id === selectedId) ?? null;
|
||
|
||
const loadSessions = useCallback(async () => {
|
||
if (!selectedEventId || !canReadAttendance) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
const res = await attendancesApi.list(selectedEventId);
|
||
setLoading(false);
|
||
if (res.error) {
|
||
setError(res.error);
|
||
return;
|
||
}
|
||
setSessions(res.data?.attendances ?? []);
|
||
}, [selectedEventId, canReadAttendance]);
|
||
|
||
useEffect(() => {
|
||
loadSessions();
|
||
}, [loadSessions]);
|
||
|
||
const loadCheckIns = useCallback(async () => {
|
||
if (!selectedEventId || !selectedId) return;
|
||
setCheckInsLoading(true);
|
||
const res = await attendancesApi.listCheckIns(selectedEventId, selectedId, { limit: 50 });
|
||
setCheckInsLoading(false);
|
||
if (res.data) {
|
||
setCheckIns(res.data.checkIns);
|
||
setCheckInsTotal(res.data.total);
|
||
}
|
||
}, [selectedEventId, selectedId]);
|
||
|
||
useEffect(() => {
|
||
if (selectedId) loadCheckIns();
|
||
}, [selectedId, loadCheckIns]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedEventId || !selectedId) return;
|
||
const t = setTimeout(async () => {
|
||
setSearching(true);
|
||
const res = await attendancesApi.searchParticipants(
|
||
selectedEventId,
|
||
selectedId,
|
||
searchQ.trim() || undefined
|
||
);
|
||
setSearching(false);
|
||
if (res.data) setSearchResults(res.data.participants);
|
||
}, 300);
|
||
return () => clearTimeout(t);
|
||
}, [searchQ, selectedEventId, selectedId]);
|
||
|
||
const openCreate = () => {
|
||
setEditingId(null);
|
||
setForm(emptyForm);
|
||
setPanelOpen(true);
|
||
};
|
||
|
||
const openEdit = (s: AttendanceRecord) => {
|
||
setEditingId(s._id);
|
||
setForm({
|
||
name: s.name,
|
||
description: s.description || "",
|
||
allowDuplicateCheckIn: Boolean(s.settings?.allowDuplicateCheckIn),
|
||
publicLinkEnabled: s.settings?.publicLinkEnabled !== false,
|
||
});
|
||
setPanelOpen(true);
|
||
};
|
||
|
||
const saveSession = async () => {
|
||
if (!selectedEventId || !form.name.trim()) return;
|
||
setSaving(true);
|
||
const payload = {
|
||
name: form.name.trim(),
|
||
description: form.description.trim(),
|
||
settings: {
|
||
allowDuplicateCheckIn: form.allowDuplicateCheckIn,
|
||
publicLinkEnabled: form.publicLinkEnabled,
|
||
},
|
||
};
|
||
const res = editingId
|
||
? await attendancesApi.update(selectedEventId, editingId, payload)
|
||
: await attendancesApi.create(selectedEventId, payload);
|
||
setSaving(false);
|
||
if (res.error) {
|
||
setError(res.error);
|
||
return;
|
||
}
|
||
setPanelOpen(false);
|
||
await loadSessions();
|
||
if (res.data?._id && !editingId) setSelectedId(res.data._id);
|
||
};
|
||
|
||
const archiveSession = async (id: string) => {
|
||
if (!selectedEventId || !confirm("Archive this attendance session?")) return;
|
||
const res = await attendancesApi.remove(selectedEventId, id);
|
||
if (res.error) {
|
||
setError(res.error);
|
||
return;
|
||
}
|
||
if (selectedId === id) setSelectedId(null);
|
||
await loadSessions();
|
||
};
|
||
|
||
const handleCheckIn = async (participantId: string) => {
|
||
if (!selectedEventId || !selectedId) return;
|
||
setCheckInBusy(participantId);
|
||
const res = await attendancesApi.checkIn(selectedEventId, selectedId, participantId);
|
||
setCheckInBusy(null);
|
||
if (res.error) {
|
||
setError(res.error);
|
||
return;
|
||
}
|
||
await loadSessions();
|
||
await loadCheckIns();
|
||
const sr = await attendancesApi.searchParticipants(
|
||
selectedEventId,
|
||
selectedId,
|
||
searchQ.trim() || undefined
|
||
);
|
||
if (sr.data) setSearchResults(sr.data.participants);
|
||
};
|
||
|
||
const copyLink = async (token: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(publicAttendanceUrl(token));
|
||
setCopyMsg("Link copied");
|
||
setTimeout(() => setCopyMsg(null), 2000);
|
||
} catch {
|
||
setCopyMsg("Could not copy");
|
||
}
|
||
};
|
||
|
||
const regenerateLink = async (id: string) => {
|
||
if (!selectedEventId || !confirm("Regenerate public link? Old links will stop working.")) return;
|
||
const res = await attendancesApi.regenerateToken(selectedEventId, id);
|
||
if (res.error) {
|
||
setError(res.error);
|
||
return;
|
||
}
|
||
await loadSessions();
|
||
};
|
||
|
||
const exportCsv = async () => {
|
||
if (!selectedEventId || !selectedId || !selected) return;
|
||
const safe = selected.name.replace(/[^a-z0-9-_]+/gi, "-").slice(0, 40);
|
||
await attendancesApi.downloadExport(selectedEventId, selectedId, `${safe}-check-ins.csv`);
|
||
};
|
||
|
||
if (!selectedEventId) {
|
||
return (
|
||
<div style={{ padding: 24, color: "var(--muted)" }}>
|
||
Select an event to manage attendance.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!canReadAttendance) {
|
||
return (
|
||
<div style={{ padding: 24, color: "var(--muted)", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>
|
||
You do not have permission to view Attendance.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
|
||
<div
|
||
style={{
|
||
padding: "16px 20px",
|
||
borderBottom: "1px solid var(--border)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 12,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||
<ClipboardCheck size={20} color="var(--primary)" />
|
||
<h1
|
||
style={{
|
||
margin: 0,
|
||
fontSize: "var(--text-lg)",
|
||
fontFamily: "var(--font-inter)",
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
Attendance
|
||
</h1>
|
||
</div>
|
||
{canWriteAttendance && (
|
||
<button
|
||
type="button"
|
||
onClick={openCreate}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "8px 14px",
|
||
background: "var(--primary)",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: "var(--radius-button)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
}}
|
||
>
|
||
<Plus size={14} /> Add session
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<div
|
||
style={{
|
||
margin: "12px 20px 0",
|
||
padding: "10px 12px",
|
||
background: "#fef2f2",
|
||
color: "#b91c1c",
|
||
borderRadius: "var(--radius-button)",
|
||
fontSize: "var(--text-sm)",
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
{error}
|
||
<button type="button" onClick={() => setError(null)} style={{ background: "none", border: "none", cursor: "pointer" }}>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
{copyMsg && (
|
||
<div style={{ margin: "8px 20px 0", fontSize: "var(--text-sm)", color: "var(--primary)" }}>{copyMsg}</div>
|
||
)}
|
||
|
||
<div style={{ display: "flex", flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||
<div
|
||
style={{
|
||
width: selectedId ? 280 : "100%",
|
||
maxWidth: selectedId ? 280 : undefined,
|
||
borderRight: selectedId ? "1px solid var(--border)" : undefined,
|
||
overflow: "auto",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{loading && <p style={{ padding: 20, color: "var(--muted)" }}>Loading…</p>}
|
||
{!loading && sessions.length === 0 && (
|
||
<p style={{ padding: 20, color: "var(--muted)", fontSize: "var(--text-sm)" }}>
|
||
No attendance sessions yet. Create one to start check-ins.
|
||
</p>
|
||
)}
|
||
{sessions.map((s) => (
|
||
<button
|
||
key={s._id}
|
||
type="button"
|
||
onClick={() => setSelectedId(s._id)}
|
||
style={{
|
||
display: "block",
|
||
width: "100%",
|
||
textAlign: "left",
|
||
padding: "14px 16px",
|
||
border: "none",
|
||
borderBottom: "1px solid var(--border)",
|
||
background: selectedId === s._id ? "var(--secondary)" : "transparent",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 600, fontSize: "var(--text-sm)", marginBottom: 4 }}>{s.name}</div>
|
||
<div style={{ fontSize: "11px", color: "var(--muted)" }}>
|
||
{s.totalCheckedIn ?? 0} check-in{(s.totalCheckedIn ?? 0) === 1 ? "" : "s"} ·{" "}
|
||
{s.uniqueParticipants ?? 0} people
|
||
</div>
|
||
{s.status === "archived" && (
|
||
<span style={{ fontSize: "10px", color: "var(--muted)" }}>Archived</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{selected && selectedEventId && (
|
||
<div style={{ flex: 1, overflow: "auto", padding: 20, minWidth: 0 }}>
|
||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12, marginBottom: 20, flexWrap: "wrap" }}>
|
||
<div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedId(null)}
|
||
style={{
|
||
display: "none",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
color: "var(--muted)",
|
||
fontSize: "var(--text-sm)",
|
||
marginBottom: 8,
|
||
}}
|
||
className="attendance-back-mobile"
|
||
>
|
||
<ChevronLeft size={16} /> Back
|
||
</button>
|
||
<h2 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>{selected.name}</h2>
|
||
{selected.description && (
|
||
<p style={{ margin: "6px 0 0", fontSize: "var(--text-sm)", color: "var(--muted)" }}>
|
||
{selected.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||
{canWriteAttendance && (
|
||
<button
|
||
type="button"
|
||
onClick={() => openEdit(selected)}
|
||
title="Edit"
|
||
style={iconBtn}
|
||
>
|
||
<Pencil size={14} />
|
||
</button>
|
||
)}
|
||
<button type="button" onClick={exportCsv} title="Export CSV" style={iconBtn}>
|
||
<Download size={14} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => copyLink(selected.publicToken)}
|
||
title="Copy public link"
|
||
style={iconBtn}
|
||
disabled={!selected.settings?.publicLinkEnabled}
|
||
>
|
||
<Link2 size={14} />
|
||
</button>
|
||
{canWriteAttendance && (
|
||
<button
|
||
type="button"
|
||
onClick={() => regenerateLink(selected._id)}
|
||
title="Regenerate link"
|
||
style={iconBtn}
|
||
>
|
||
<RefreshCw size={14} />
|
||
</button>
|
||
)}
|
||
{canDeleteAttendance && (
|
||
<button type="button" onClick={() => archiveSession(selected._id)} title="Archive" style={iconBtn}>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 24 }}>
|
||
<label style={{ display: "block", fontSize: "var(--text-sm)", fontWeight: 500, marginBottom: 8 }}>
|
||
<Search size={14} style={{ verticalAlign: "middle", marginRight: 6 }} />
|
||
Find participant to check in
|
||
</label>
|
||
<input
|
||
type="search"
|
||
value={searchQ}
|
||
onChange={(e) => setSearchQ(e.target.value)}
|
||
placeholder="Name, email, or phone…"
|
||
style={inputStyle}
|
||
/>
|
||
{searching && <p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginTop: 8 }}>Searching…</p>}
|
||
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{searchResults.map((p) => {
|
||
const canCheckIn =
|
||
!p.alreadyCheckedIn || Boolean(selected.settings?.allowDuplicateCheckIn);
|
||
return (
|
||
<div
|
||
key={p._id}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 12,
|
||
padding: "12px 14px",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: "var(--radius-button)",
|
||
background: "var(--card)",
|
||
}}
|
||
>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontWeight: 500, fontSize: "var(--text-sm)" }}>{p.displayName}</div>
|
||
<div style={{ fontSize: "11px", color: "var(--muted)" }}>
|
||
{[p.email, p.phone].filter(Boolean).join(" · ") || "—"}
|
||
</div>
|
||
{p.alreadyCheckedIn && (
|
||
<div style={{ fontSize: "11px", color: "#15803d", marginTop: 4 }}>
|
||
Checked in {p.checkInCount} time{p.checkInCount === 1 ? "" : "s"}
|
||
{p.lastCheckedInAt ? ` · ${formatDt(p.lastCheckedInAt)}` : ""}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={!canWriteAttendance || !canCheckIn || checkInBusy === p._id}
|
||
onClick={() => handleCheckIn(p._id)}
|
||
style={{
|
||
...checkInBtn,
|
||
opacity: canWriteAttendance && canCheckIn ? 1 : 0.5,
|
||
}}
|
||
>
|
||
<UserCheck size={14} />
|
||
{p.alreadyCheckedIn && selected.settings?.allowDuplicateCheckIn
|
||
? "Again"
|
||
: "Check in"}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
{!searching && searchResults.length === 0 && searchQ.trim() && (
|
||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>No participants found.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 style={{ fontSize: "var(--text-sm)", fontWeight: 600, marginBottom: 12 }}>
|
||
Check-ins ({checkInsTotal})
|
||
</h3>
|
||
{checkInsLoading && <p style={{ color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading…</p>}
|
||
{!checkInsLoading && checkIns.length === 0 && (
|
||
<p style={{ color: "var(--muted)", fontSize: "var(--text-sm)" }}>No check-ins yet.</p>
|
||
)}
|
||
<div style={{ overflowX: "auto" }}>
|
||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "var(--text-sm)" }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: "1px solid var(--border)", textAlign: "left" }}>
|
||
<th style={thStyle}>Name</th>
|
||
<th style={thStyle}>Email</th>
|
||
<th style={thStyle}>Phone</th>
|
||
<th style={thStyle}>Last check-in</th>
|
||
<th style={thStyle}>Count</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{checkIns.map((row) => {
|
||
const name = [row.firstName, row.lastName].filter(Boolean).join(" ").trim() || "—";
|
||
return (
|
||
<tr key={row._id} style={{ borderBottom: "1px solid var(--border)" }}>
|
||
<td style={tdStyle}>{name}</td>
|
||
<td style={tdStyle}>{row.email || "—"}</td>
|
||
<td style={tdStyle}>{row.phone || "—"}</td>
|
||
<td style={tdStyle}>{formatDt(row.lastCheckedInAt)}</td>
|
||
<td style={tdStyle}>{row.checkInCount}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!selected && sessions.length > 0 && (
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
color: "var(--muted)",
|
||
fontSize: "var(--text-sm)",
|
||
}}
|
||
>
|
||
Select a session to manage check-ins
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{panelOpen && (
|
||
<div
|
||
style={{
|
||
position: "fixed",
|
||
inset: 0,
|
||
background: "rgba(0,0,0,0.35)",
|
||
zIndex: 200,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 24,
|
||
}}
|
||
onClick={() => setPanelOpen(false)}
|
||
>
|
||
<div
|
||
style={{
|
||
background: "var(--card)",
|
||
borderRadius: "var(--radius-card)",
|
||
width: "100%",
|
||
maxWidth: 440,
|
||
boxShadow: "var(--elevation-sm)",
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div style={{ padding: "20px 24px", borderBottom: "1px solid var(--border)" }}>
|
||
<h3 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>
|
||
{editingId ? "Edit session" : "New attendance session"}
|
||
</h3>
|
||
</div>
|
||
<div style={{ padding: "20px 24px", display: "flex", flexDirection: "column", gap: 14 }}>
|
||
<label style={labelStyle}>
|
||
Name *
|
||
<input
|
||
value={form.name}
|
||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||
style={inputStyle}
|
||
placeholder="e.g. Main entrance – Day 1"
|
||
/>
|
||
</label>
|
||
<label style={labelStyle}>
|
||
Description
|
||
<textarea
|
||
value={form.description}
|
||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||
style={{ ...inputStyle, minHeight: 72, resize: "vertical" }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={form.allowDuplicateCheckIn}
|
||
onChange={(e) => setForm((f) => ({ ...f, allowDuplicateCheckIn: e.target.checked }))}
|
||
/>
|
||
Allow duplicate check-ins
|
||
</label>
|
||
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={form.publicLinkEnabled}
|
||
onChange={(e) => setForm((f) => ({ ...f, publicLinkEnabled: e.target.checked }))}
|
||
/>
|
||
Enable public check-in link
|
||
</label>
|
||
</div>
|
||
<div
|
||
style={{
|
||
padding: "14px 24px",
|
||
borderTop: "1px solid var(--border)",
|
||
display: "flex",
|
||
justifyContent: "flex-end",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<button type="button" onClick={() => setPanelOpen(false)} style={secondaryBtn}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={saveSession}
|
||
disabled={saving || !form.name.trim()}
|
||
style={primaryBtn}
|
||
>
|
||
{saving ? "Saving…" : editingId ? "Save" : "Create"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const iconBtn: React.CSSProperties = {
|
||
width: 32,
|
||
height: 32,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
background: "var(--card)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: "var(--radius-button)",
|
||
cursor: "pointer",
|
||
color: "var(--muted)",
|
||
};
|
||
|
||
const inputStyle: React.CSSProperties = {
|
||
width: "100%",
|
||
height: 40,
|
||
padding: "0 12px",
|
||
marginTop: 6,
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--card)",
|
||
fontSize: "var(--text-sm)",
|
||
boxSizing: "border-box",
|
||
};
|
||
|
||
const labelStyle: React.CSSProperties = {
|
||
display: "block",
|
||
fontSize: "var(--text-sm)",
|
||
fontWeight: 500,
|
||
};
|
||
|
||
const thStyle: React.CSSProperties = { padding: "8px 10px", fontWeight: 600, color: "var(--muted)" };
|
||
const tdStyle: React.CSSProperties = { padding: "10px" };
|
||
|
||
const checkInBtn: React.CSSProperties = {
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "8px 12px",
|
||
background: "var(--primary)",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: "var(--radius-button)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
flexShrink: 0,
|
||
};
|
||
|
||
const secondaryBtn: React.CSSProperties = {
|
||
padding: "8px 16px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--card)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
};
|
||
|
||
const primaryBtn: React.CSSProperties = {
|
||
padding: "8px 16px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "none",
|
||
background: "var(--primary)",
|
||
color: "#fff",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
};
|