// checklist.jsx — 行前準備清單
function _checkApi(method, path, body) {
const opts = { method, headers: { Authorization: `Bearer ${window.tabiToken()}` } };
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
return fetch('/api/checklist' + path, opts).then(r => {
if (r.status === 204) return null;
return r.ok ? r.json() : r.json().then(d => Promise.reject(d.detail || '操作失敗'));
});
}
// ── ChecklistItemDetailSheet ──────────────────────────────────
// Edit or view a single item's details (text, note, image, url)
function ChecklistItemDetailSheet({ item, categories, isNew, onClose, onSaved, onDeleted }) {
const [text, setText] = React.useState(item?.text || '');
const [category, setCategory] = React.useState(item?.category || categories[0] || '其他');
const [note, setNote] = React.useState(item?.note || '');
const [url, setUrl] = React.useState(item?.url || '');
const [imgPreview, setImgPreview] = React.useState(item?.image_url || null);
const [imgFile, setImgFile] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState('');
const fileRef = React.useRef();
const [lightbox, setLightbox] = React.useState(false);
const inputStyle = {
width:'100%', padding:'10px 12px', borderRadius:10, fontSize:16, minHeight:44,
border:`1px solid ${TABI_COLORS.line}`, background:TABI_COLORS.bg,
color:TABI_COLORS.ink, outline:'none', fontFamily:'inherit', boxSizing:'border-box',
};
const selStyle = {
...inputStyle,
appearance:'none', WebkitAppearance:'none',
backgroundImage:`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238D8071' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E")`,
backgroundRepeat:'no-repeat', backgroundPosition:'right 10px center', paddingRight:28, cursor:'pointer',
};
function pickImg(e) {
const f = e.target.files[0];
if (!f) return;
setImgFile(f);
setImgPreview(URL.createObjectURL(f));
}
async function save() {
if (!text.trim()) { setErr('請輸入項目內容'); return; }
setSaving(true);
try {
let saved;
if (isNew) {
saved = await _checkApi('POST', '/items', { text: text.trim(), category, note: note.trim() || null, url: url.trim() || null });
} else {
saved = await _checkApi('PATCH', `/items/${item.id}`, { text: text.trim(), category, note: note.trim() || null, url: url.trim() || null });
}
// Upload image if selected
if (imgFile && saved?.id) {
const fd = new FormData();
fd.append('image', imgFile);
await fetch(`/api/checklist/items/${saved.id}/upload`, {
method: 'POST', headers: { Authorization: `Bearer ${window.tabiToken()}` }, body: fd,
});
}
onSaved();
} catch (e) { setErr(String(e)); setSaving(false); }
}
async function del() {
if (!confirm(`刪除「${text}」?`)) return;
await _checkApi('DELETE', `/items/${item.id}`);
onDeleted();
}
return ReactDOM.createPortal(
<>
e.target === e.currentTarget && onClose()}>
{isNew ? '新增項目' : '編輯項目'}
項目內容 *
setText(e.target.value)} placeholder="例:確認護照效期" style={inputStyle} />
類別
細項說明(選填)
{!isNew && (
)}
{/* Lightbox */}
{lightbox && ReactDOM.createPortal(
setLightbox(false)} style={{ position:'fixed', inset:0, zIndex:9999, background:'rgba(0,0,0,0.88)', display:'flex', alignItems:'center', justifyContent:'center', cursor:'zoom-out' }}>

e.stopPropagation()} />
,
document.body
)}
>,
document.body
);
}
// ── ChecklistScreen ───────────────────────────────────────────
function ChecklistScreen({ go }) {
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [editItem, setEditItem] = React.useState(null); // item to edit, or 'new'
const [collapsed, setCollapsed] = React.useState({});
const [hasTemplate, setHasTemplate] = React.useState(false);
const [showMenu, setShowMenu] = React.useState(false);
async function load() {
setLoading(true);
try {
const [data, tplData] = await Promise.all([
_checkApi('GET', '/items'),
_checkApi('GET', '/template'),
]);
setItems(Array.isArray(data) ? data : []);
setHasTemplate(tplData?.has_template || false);
} catch {} finally { setLoading(false); }
}
React.useEffect(() => { load(); }, []);
async function toggle(item) {
await _checkApi('PATCH', `/items/${item.id}`, { is_done: !item.is_done });
setItems(prev => prev.map(i => i.id === item.id ? {...i, is_done: !i.is_done} : i));
}
async function saveAsTemplate() {
setShowMenu(false);
await _checkApi('POST', '/template/save');
setHasTemplate(true);
alert('✓ 已儲存為我的模板');
}
async function resetChecklist() {
setShowMenu(false);
const msg = hasTemplate ? '重製清單?將清除目前進度並套用你儲存的模板。' : '重製清單?將清除目前進度並套用預設模板。';
if (!confirm(msg)) return;
await _checkApi('POST', '/reset');
load();
}
// Group by category maintaining order
const categories = [...new Set(items.map(i => i.category))];
const grouped = {};
categories.forEach(c => { grouped[c] = items.filter(i => i.category === c); });
const doneCount = items.filter(i => i.is_done).length;
const total = items.length;
const progress = total > 0 ? doneCount / total : 0;
return (
go('home')}
right={
{showMenu && ReactDOM.createPortal(
setShowMenu(false)}>
e.stopPropagation()}>
{[
{ icon:'💾', label:'儲存為我的模板', sub:'把目前清單存成下次的起點', action: saveAsTemplate },
{ icon:'🔄', label: hasTemplate ? '重製清單(套用我的模板)' : '重製清單(套用預設模板)', sub:'清空進度重新開始', action: resetChecklist, color: TABI_COLORS.red },
].map((a, i) => (
))}
,
document.body
)}
}
/>
{/* Progress bar */}
{total > 0 && (
完成進度
{doneCount} / {total}
{doneCount === total && total > 0 && (
🎉 全部完成!出發吧!
)}
)}
{loading &&
載入中…
}
{/* Items grouped by category */}
{!loading && categories.map(cat => {
const catItems = grouped[cat] || [];
const catDone = catItems.filter(i => i.is_done).length;
const isCollapsed = collapsed[cat];
return (
{!isCollapsed && (
{catItems.map((item, idx) => (
0 ? `1px solid ${TABI_COLORS.line}` : 'none',
background: item.is_done ? `${TABI_COLORS.green}06` : 'transparent',
}}>
{/* Checkbox */}
{/* Image thumbnail */}
{item.image_url && (

setEditItem(item)}
style={{ width:44, height:44, objectFit:'cover', borderRadius:8, flexShrink:0, border:`1px solid ${TABI_COLORS.line}`, cursor:'pointer' }} />
)}
{/* Content */}
{/* Edit button */}
))}
)}
);
})}
{!loading && items.length === 0 && (
)}
{/* Add FAB */}
{ReactDOM.createPortal(
,
document.body
)}
{/* Detail sheet */}
{editItem && (
setEditItem(null)}
onSaved={() => { setEditItem(null); load(); }}
onDeleted={() => { setEditItem(null); load(); }}
/>
)}
);
}