// Column types: metadata, cell display/editing, pickers and group-footer summaries.
import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs';
import { state, toast } from './store.js';
import { api } from './api.js';
import { setValue, patchColumn } from './actions.js';
import {
Icon, Avatar, AvatarStack, openPopover, closePopover, InlineEdit, fmtDate, fmtRange, parseDate, ymd, todayStr,
textOn, LABEL_COLORS, fileUrl, isImage, fmtSize, timeAgo, ColorPicker,
} from './ui.js';
export const COLUMN_TYPES = {
status: { label: 'Status', icon: 'check', color: '#00c875' },
priority: { label: 'Priority', icon: 'bolt', color: '#401694' },
text: { label: 'Text', icon: 'edit', color: '#fdab3d' },
people: { label: 'People', icon: 'person', color: '#579bfc' },
date: { label: 'Date', icon: 'calendar', color: '#a25ddc' },
timeline: { label: 'Timeline', icon: 'timeline', color: '#9d50dd' },
number: { label: 'Numbers', icon: 'chart', color: '#ffcb00' },
long_text: { label: 'Long text', icon: 'message', color: '#ff642e' },
dropdown: { label: 'Dropdown', icon: 'chevDown', color: '#00a9ff' },
tags: { label: 'Tags', icon: 'pin', color: '#00c875' },
checkbox: { label: 'Checkbox', icon: 'check', color: '#ff7575' },
files: { label: 'Files', icon: 'paperclip', color: '#f65f7c' },
link: { label: 'Link', icon: 'link', color: '#66ccff' },
email: { label: 'Email', icon: 'message', color: '#0086c0' },
phone: { label: 'Phone', icon: 'message', color: '#037f4c' },
rating: { label: 'Rating', icon: 'star', color: '#ffcb00' },
created: { label: 'Creation log', icon: 'activity', color: '#7f5347' },
updated: { label: 'Last updated', icon: 'refresh', color: '#7f5347' },
item_id: { label: 'Item ID', icon: 'info', color: '#676879' },
};
export const DEFAULT_WIDTH = { status: 140, people: 110, date: 120, timeline: 170, number: 110, checkbox: 90, rating: 130, files: 120, text: 170, long_text: 200, dropdown: 170, tags: 170, link: 170, email: 180, phone: 140, created: 160, updated: 140, item_id: 110 };
export const colWidth = (c) => c.width || DEFAULT_WIDTH[c.type] || 150;
export function labelOf(col, id) { return (col.settings?.labels || []).find((l) => l.id === id); }
export function isDoneLabel(l) { return !!l && (l.done || /^done$/i.test(l.name)); }
// Is the item "done"? — first status column with a done label.
export function itemDone(board, item) {
for (const c of board.columns) if (c.type === 'status' && isDoneLabel(labelOf(c, item.vals[c.id]))) return true;
return false;
}
// Plain-text value, for search, sort and filters.
export function cellText(board, col, item) {
const v = item.vals[col.id];
switch (col.type) {
case 'status': return labelOf(col, v)?.name || '';
case 'dropdown': return (v || []).map((x) => labelOf(col, x)?.name).filter(Boolean).join(', ');
case 'people': return (v || []).map((u) => state.usersById[u]?.name).filter(Boolean).join(', ');
case 'timeline': return v?.from ? `${v.from} ${v.to}` : '';
case 'link': return v ? (v.text || v.url || '') : '';
case 'tags': return (v || []).join(', ');
case 'checkbox': return v ? 'checked' : '';
case 'files': return (board.files || []).filter((f) => (v || []).includes(f.id)).map((f) => f.name).join(', ');
case 'created': return state.usersById[item.created_by]?.name || '';
default: return v == null ? '' : String(v);
}
}
// ─── Cell ────────────────────────────────────────────────
export function Cell({ board, col, item, compact }) {
const v = item.vals[col.id];
const boardId = board.board.id;
const set = (val) => setValue(boardId, item.id, col.id, val);
const open = (e, Comp, extra = {}, opts = {}) => { e.stopPropagation(); openPopover(e.currentTarget, Comp, { board, col, item, set, ...extra }, opts); };
switch (col.type) {
case 'status': {
const l = labelOf(col, v);
return html``;
}
case 'people':
return html``;
case 'date': {
const d = parseDate(v);
const overdue = d && ymd(d) < todayStr() && !itemDone(board, item);
const today = d && ymd(d) === todayStr();
return html``;
}
case 'timeline': {
const g = board.groups.find((x) => x.id === (item.group_id || board.items.find((i) => i.id === item.parent_id)?.group_id));
const pct = v?.from ? timelineProgress(v) : 0;
return html``;
}
case 'number':
return html`
<${InlineEdit} value=${v == null ? '' : String(v)} placeholder=""
onSave=${(s) => { const n = s === '' ? null : Number(s.replace(/[^0-9.\-]/g, '')); if (s !== '' && !Number.isFinite(n)) return toast('Numbers only', 'error'); set(n); }} />
${v != null && col.settings?.unit ? html`${col.settings.unit}` : null}
`;
case 'checkbox':
return html``;
case 'rating': {
const max = col.settings?.max || 5;
return html`${Array.from({ length: max }, (_, i) => html`
`)}
`;
}
case 'long_text':
return html``;
case 'dropdown':
return html``;
case 'tags':
return html``;
case 'link':
return html``;
case 'email':
return html``;
case 'phone':
return html``;
case 'files': {
const files = (board.files || []).filter((f) => (v || []).includes(f.id));
return html``;
}
case 'created':
return html`<${Avatar} user=${state.usersById[item.created_by]} size=${22} />${fmtDate(ymd(new Date(item.created_at)))}
`;
case 'updated':
return html`${timeAgo(item.updated_at)} ago
`;
case 'item_id':
return html`${item.id}
`;
default:
return html`<${InlineEdit} value=${v || ''} onSave=${(s) => set(s || null)} />
`;
}
}
const ext = (n) => (n.split('.').pop() || 'file').slice(0, 4).toUpperCase();
function timelineProgress(v) {
const a = parseDate(v.from); const b = parseDate(v.to || v.from);
const now = new Date(); now.setHours(0, 0, 0, 0);
if (now < a) return 0;
if (now >= b) return 100;
return Math.round(((now - a) / (b - a + 86400000)) * 100);
}
// ─── Status picker (with label editing) ──────────────────
export function StatusPicker({ board, col, set, item, onPick }) {
const [editing, setEditing] = useState(false);
const labels = col.settings?.labels || [];
const pick = (id) => { closePopover(); onPick ? onPick(id) : set(id); };
if (editing) return html`<${LabelEditor} board=${board} col=${col} onDone=${() => setEditing(false)} />`;
return html`
${labels.map((l) => html``)}
`;
}
function LabelEditor({ board, col, onDone }) {
const [labels, setLabels] = useState(() => (col.settings?.labels || []).map((l) => ({ ...l })));
const save = () => { patchColumn(board.board.id, col.id, { settings: { ...col.settings, labels: labels.filter((l) => l.name.trim()) } }); onDone(); };
const upd = (i, patch) => setLabels(labels.map((l, j) => (j === i ? { ...l, ...patch } : l)));
return html`
${labels.map((l, i) => html`
`)}
setLabels([...labels, { id: Math.random().toString(36).slice(2, 9), name: 'New label', color: LABEL_COLORS[labels.length % LABEL_COLORS.length] }])}><${Icon} name="plus" /> New label
CancelApply
`;
}
// ─── Dropdown labels (multi) ─────────────────────────────
function LabelsPicker({ board, col, item, set }) {
const [q, setQ] = useState('');
const sel = item.vals[col.id] || [];
const labels = col.settings?.labels || [];
const shown = labels.filter((l) => l.name.toLowerCase().includes(q.toLowerCase()));
const toggle = (id) => set(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]);
const create = () => {
const l = { id: Math.random().toString(36).slice(2, 9), name: q.trim(), color: LABEL_COLORS[labels.length % LABEL_COLORS.length] };
patchColumn(board.board.id, col.id, { settings: { ...col.settings, labels: [...labels, l] } });
set([...sel, l.id]); setQ('');
};
return html`
setQ(e.target.value)} autofocus
onKeyDown=${(e) => { if (e.key === 'Enter' && q.trim()) { const ex = labels.find((l) => l.name.toLowerCase() === q.trim().toLowerCase()); ex ? toggle(ex.id) : create(); } }} />
${shown.map((l) => html`
toggle(l.id)}>
<${Icon} name="check" size=${12} />${l.name}`)}
${q.trim() && !labels.some((l) => l.name.toLowerCase() === q.trim().toLowerCase()) ? html`
<${Icon} name="plus" /> Create "${q.trim()}"` : null}
${!labels.length && !q ? html`
Type to add the first option
` : null}
`;
}
function TagsEditor({ col, item, set }) {
const [q, setQ] = useState('');
const tags = item.vals[col.id] || [];
return html``;
}
// ─── People ──────────────────────────────────────────────
export function PeoplePicker({ col, item, set, value, onChange, single }) {
const [q, setQ] = useState('');
const sel = value ?? (item?.vals[col.id] || []);
const apply = onChange || set;
const users = state.users.filter((u) => u.active && (u.name + ' ' + u.email).toLowerCase().includes(q.toLowerCase()));
const toggle = (id) => {
if (single) { apply(sel.includes(id) ? [] : [id]); closePopover(); return; }
apply(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]);
};
const chosen = sel.map((id) => state.usersById[id]).filter(Boolean);
return html`
${chosen.length ? html`
${chosen.map((u) => html`<${Avatar} user=${u} size=${20} />${u.name}
toggle(u.id)} aria-label="Remove"><${Icon} name="x" size=${10} />`)}
` : null}
setQ(e.target.value)} autofocus />
Suggested people
${users.filter((u) => !sel.includes(u.id)).slice(0, 50).map((u) => html`
toggle(u.id)}>
<${Avatar} user=${u} size=${24} />${u.name}${u.email}`)}
${!users.length ? html`
No one matches "${q}"
` : null}
`;
}
// ─── Dates ───────────────────────────────────────────────
export function DatePicker({ col, item, set, range, value: v0, onChange }) {
const value = v0 !== undefined ? v0 : item?.vals[col.id];
const apply = onChange || set;
const init = range ? parseDate(value?.from) : parseDate(value);
const [month, setMonth] = useState(() => { const d = init || new Date(); return new Date(d.getFullYear(), d.getMonth(), 1); });
const [from, setFrom] = useState(range ? value?.from || null : null);
const [time, setTime] = useState(!range && String(value || '').includes('T') ? String(value).split('T')[1] : '');
const [showTime, setShowTime] = useState(!!time);
const sel = range ? null : (value ? String(value).slice(0, 10) : null);
const days = [];
const start = new Date(month); start.setDate(1 - start.getDay());
for (let i = 0; i < 42; i++) { const d = new Date(start); d.setDate(start.getDate() + i); days.push(d); }
const pending = useRef(false);
const click = (d) => {
const s = ymd(d);
if (!range) { apply(showTime && time ? `${s}T${time}` : s); closePopover(); return; }
if (!pending.current) { setFrom(s); pending.current = true; apply({ from: s, to: s }); return; }
const [a, b] = s < from ? [s, from] : [from, s];
pending.current = false; apply({ from: a, to: b }); closePopover();
};
const inRange = (s) => range && value?.from && s >= value.from && s <= (value.to || value.from);
return html`
setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><${Icon} name="chevLeft" />
${month.toLocaleString([], { month: 'long', year: 'numeric' })}
setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><${Icon} name="chevRight" />
${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => html`${d}`)}
${days.map((d) => { const s = ymd(d); return html` click(d)}>${d.getDate()}`; })}
${range ? html`
${pending.current ? 'Now pick the end date' : 'Pick a start date, then an end date'}
` : null}
${showTime && !range ? html`
setTime(e.target.value)} />
{ apply(time ? `${sel}T${time}` : sel); closePopover(); }}>Set
` : null}
`;
}
// ─── Text-ish editors ────────────────────────────────────
function LongTextEditor({ col, item, set }) {
const [v, setV] = useState(item.vals[col.id] || '');
const save = () => { if (v !== (item.vals[col.id] || '')) set(v.trim() || null); closePopover(); };
return html``;
}
function LinkEditor({ col, item, set }) {
const cur = item.vals[col.id] || {};
const [url, setUrl] = useState(cur.url || '');
const [text, setText] = useState(cur.text || '');
const save = (e) => { e.preventDefault(); let u = url.trim(); if (u && !/^[a-z]+:/i.test(u)) u = 'https://' + u; set(u ? { url: u, text: text.trim() } : null); closePopover(); };
return html``;
}
function SimpleEditor({ col, item, set, kind }) {
const [v, setV] = useState(item.vals[col.id] || '');
const save = (e) => { e.preventDefault(); set(v.trim() || null); closePopover(); };
return html``;
}
// ─── Files ───────────────────────────────────────────────
export function FilesEditor({ board, col, item }) {
const ids = item.vals[col.id] || [];
const files = (board.files || []).filter((f) => ids.includes(f.id));
const [busy, setBusy] = useState(false);
const input = useRef();
const upload = async (list) => {
if (!list?.length) return;
setBusy(true);
const fd = new FormData();
for (const f of list) fd.append('files', f);
fd.append('column_id', col.id);
try { await api.post(`/api/items/${item.id}/files`, fd); const { loadBoard } = await import('./store.js'); await loadBoard(board.board.id); }
catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
const remove = async (f) => {
await api.del(`/api/files/${f.id}`);
const { loadBoard } = await import('./store.js'); loadBoard(board.board.id);
};
return html` e.preventDefault()} onDrop=${(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}>
${files.map((f) => html`
`)}
${!files.length ? html`
Drop files here
` : null}
upload(e.target.files)} />
input.current.click()}><${Icon} name="upload" /> ${busy ? 'Uploading…' : 'Add files'}
`;
}
// ─── Group footer summaries ──────────────────────────────
export function Summary({ board, col, items }) {
if (col.type === 'status' || col.type === 'dropdown') {
const counts = {};
let total = 0;
for (const it of items) {
const vs = col.type === 'status' ? [it.vals[col.id] || '_'] : (it.vals[col.id]?.length ? it.vals[col.id] : ['_']);
for (const v of vs) { counts[v] = (counts[v] || 0) + 1; total++; }
}
if (!total) return html``;
const segs = Object.entries(counts).map(([id, n]) => ({ l: labelOf(col, id), n }))
.sort((a, b) => (a.l ? 0 : 1) - (b.l ? 0 : 1));
return html` `${s.l?.name || 'Empty'}: ${s.n}`).join('\n')}>
${segs.map((s, i) => html``)}
`;
}
if (col.type === 'number') {
const nums = items.map((i) => i.vals[col.id]).filter((x) => typeof x === 'number');
const sum = nums.reduce((a, b) => a + b, 0);
return html`${nums.length ? html`${+sum.toFixed(2)}${col.settings?.unit ? ' ' + col.settings.unit : ''}sum` : null}
`;
}
if (col.type === 'date' || col.type === 'timeline') {
const ds = items.flatMap((i) => { const v = i.vals[col.id]; return !v ? [] : col.type === 'date' ? [String(v).slice(0, 10)] : [v.from, v.to]; }).filter(Boolean).sort();
if (!ds.length) return html``;
return html`${fmtRange({ from: ds[0], to: ds[ds.length - 1] })}
`;
}
if (col.type === 'people') {
const ids = [...new Set(items.flatMap((i) => i.vals[col.id] || []))];
return html`${ids.length ? html`<${AvatarStack} ids=${ids} size=${22} />` : null}
`;
}
if (col.type === 'checkbox') {
const n = items.filter((i) => i.vals[col.id]).length;
return html`${items.length ? `${n}/${items.length}` : ''}
`;
}
if (col.type === 'files') {
const n = items.reduce((a, i) => a + (i.vals[col.id]?.length || 0), 0);
return html`${n ? `${n} files` : ''}
`;
}
return html``;
}
export { ColorPicker };