eight-hourr/frontend/app.js

287 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Home flow:
* - range mode: start~end, skip TW holidays/weekends
* - list mode: exact dates, fill even on holidays
* Preview/fill: Teams meetings + GitLab issues → PTS
*/
function setDefaultDates() {
const today = todayStr();
document.getElementById("startDate").value = today;
document.getElementById("endDate").value = today;
}
function getDateMode() {
const checked = document.querySelector('input[name="dateMode"]:checked');
return checked ? checked.value : "range";
}
function getDateRange() {
const start = document.getElementById("startDate")?.value || todayStr();
let end = document.getElementById("endDate")?.value || start;
if (end < start) {
document.getElementById("endDate").value = start;
document.getElementById("startDate").value = end;
return { start: end, end: start };
}
return { start, end };
}
function getDateListText() {
return (document.getElementById("dateList")?.value || "").trim();
}
function parseDateList(text) {
const dates = [];
for (const line of text.split(/\n+/)) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
for (const part of t.split(/[\s,;]+/)) {
const d = part.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(d)) dates.push(d);
}
}
return [...new Set(dates)].sort();
}
function setRangeToToday() {
const today = todayStr();
document.getElementById("startDate").value = today;
document.getElementById("endDate").value = today;
updateRangeMeta();
}
function updateDateModeUI() {
const mode = getDateMode();
document.getElementById("rangePanel")?.classList.toggle("hidden", mode !== "range");
document.getElementById("listPanel")?.classList.toggle("hidden", mode !== "list");
updateRangeMeta();
}
function updateRangeMeta() {
const el = document.getElementById("rangeMeta");
if (!el) return;
if (getDateMode() === "list") {
const dates = parseDateList(getDateListText());
el.textContent = dates.length
? `指定 ${dates.length} 天(含假日也填):${dates.join("、")}`
: "請輸入至少一個日期";
return;
}
const { start, end } = getDateRange();
el.textContent = `區間 ${start}${end}(自動跳過週末/台灣假日)`;
}
function setActionStatus(text, ok) {
const el = document.getElementById("actionStatus");
if (!el) return;
el.textContent = text || "";
el.className = "settings-status" + (ok === true ? " ok" : ok === false ? " err" : "");
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function refreshConn() {
const line = document.getElementById("connLine");
try {
const data = await api("/api/status");
const teams = data.teams_calendar || {};
const parts = [
data.has_pts_session ? "工時 ✓" : "工時 ✗",
data.gitlab_configured && !data.gitlab_error ? "任務 ✓" : "任務 ✗",
data.teams_calendar_enabled ? (teams.connected ? "行事曆 ✓" : "行事曆 ✗") : "行事曆 關",
];
if (line) {
line.textContent = parts.join(" · ");
if (data.gitlab_error) line.textContent += " — " + data.gitlab_error;
}
} catch (err) {
if (line) line.textContent = err.message;
}
}
function renderPlan(data, { filled } = {}) {
const root = document.getElementById("planView");
if (!root) return;
const workdays = data.workdays || [];
const plan = data.plan || {};
const meetings = data.analysis?.meetings || data.meetings || {};
const source = data.source || "";
const gitlabN = data.gitlab_task_count ?? "—";
const meetingNote =
meetings.enabled === false
? "會議未啟用"
: meetings.error
? `會議:${meetings.error}`
: meetings.meeting_count != null
? `會議 ${meetings.meeting_count} 場 · ${meetings.meeting_hours_total ?? 0}h`
: "";
if (!workdays.length && !Object.keys(plan).length) {
root.innerHTML = `<p class="hint">沒有可填日期。區間模式會跳過假日;指定多日請確認日期格式。</p>`;
return;
}
const days = workdays.length ? workdays : Object.keys(plan).sort();
const dayBlocks = days
.map((day) => {
const entries = plan[day] || [];
if (!entries.length) {
return `<div class="plan-day">
<div class="plan-day-head"><strong>${escapeHtml(day)}</strong><span class="muted-inline">無項目</span></div>
</div>`;
}
const hours = entries.reduce((s, e) => s + Number(e.take_hours || 0), 0);
const rows = entries
.map((e) => {
const desc = String(e.description || "");
const isMeeting =
(e.pts_task_name || "").toLowerCase().includes("meet") ||
desc.toLowerCase().includes("meeting") ||
desc.toLowerCase().includes("attended");
const tag = isMeeting
? '<span class="tag">會議</span>'
: e.gitlab_task_id
? '<span class="tag">GitLab</span>'
: "";
return `<li class="plan-row">
<span class="meeting-hours">${escapeHtml(String(e.take_hours))}h</span>
<span class="meeting-subject">${tag}${escapeHtml(e.pts_task_name || "")} · ${escapeHtml(desc)}</span>
</li>`;
})
.join("");
return `<div class="plan-day">
<div class="plan-day-head">
<strong>${escapeHtml(day)}</strong>
<span class="muted-inline">${entries.length} 筆 · ${hours}h</span>
</div>
<ul class="meeting-items">${rows}</ul>
</div>`;
})
.join("");
const title = filled ? "已填寫" : "預覽";
const dry = data.dry_run ? "(試跑)" : "";
root.innerHTML = `
<div class="meetings-summary">
${pill(title + dry, source || "ok", true)}
${pill("天數", String(days.length), null)}
${pill("GitLab", String(gitlabN), null)}
${meetingNote ? pill("Teams", meetingNote, meetings.error ? false : null) : ""}
</div>
<div class="meetings-list">${dayBlocks}</div>
`;
}
async function runPreview() {
const mode = getDateMode();
setActionStatus("預覽中…");
document.getElementById("planView").innerHTML = `<p class="hint">會議 + GitLab 分配中…</p>`;
try {
let data;
if (mode === "list") {
const dates = parseDateList(getDateListText());
if (!dates.length) throw new Error("請至少輸入一個日期YYYY-MM-DD");
data = await api("/api/preview/dates", {
method: "POST",
body: JSON.stringify({ dates_text: dates.join("\n") }),
});
document.getElementById("rangeMeta").textContent =
`指定 ${dates.length} 天(含假日):${dates.join("、")}`;
} else {
const { start, end } = getDateRange();
data = await api(
`/api/preview?start_date=${encodeURIComponent(start)}&end_date=${encodeURIComponent(end)}`
);
document.getElementById("rangeMeta").textContent =
`區間 ${start}${end} · 工作日 ${(data.workdays || []).length} 天(已跳過假日)`;
}
renderPlan(data, { filled: false });
setActionStatus("預覽完成(尚未送出)", true);
} catch (err) {
document.getElementById("planView").innerHTML =
`<p class="settings-status err">${escapeHtml(err.message)}</p>`;
setActionStatus(err.message, false);
}
}
async function runFill() {
const mode = getDateMode();
const dryRun = document.getElementById("dryRun").checked;
setActionStatus(`${dryRun ? "試跑" : "填寫"}中…`);
document.getElementById("planView").innerHTML = `<p class="hint">處理中…</p>`;
try {
let data;
if (mode === "list") {
const dates = parseDateList(getDateListText());
if (!dates.length) throw new Error("請至少輸入一個日期YYYY-MM-DD");
data = await api("/api/fill/dates", {
method: "POST",
body: JSON.stringify({
dates_text: dates.join("\n"),
dry_run: dryRun,
skip_existing: true,
}),
});
document.getElementById("rangeMeta").textContent =
`指定 ${dates.length}` + (dryRun ? " · 試跑" : " · 已送出");
} else {
const { start, end } = getDateRange();
data = await api("/api/fill/range", {
method: "POST",
body: JSON.stringify({
start_date: start,
end_date: end,
dry_run: dryRun,
skip_existing: true,
}),
});
document.getElementById("rangeMeta").textContent =
`區間 ${start}${end}` + (dryRun ? " · 試跑" : " · 已送出");
}
if (data.plan || data.workdays) {
renderPlan({ ...data, dry_run: dryRun }, { filled: !dryRun });
} else {
document.getElementById("planView").innerHTML = `
<div class="meetings-summary">
${pill(dryRun ? "試跑完成" : "填寫完成", "ok", true)}
${pill("新增", String((data.created || []).length), null)}
${pill("略過", String((data.skipped || []).length), null)}
</div>
<pre class="preview">${escapeHtml(JSON.stringify(data, null, 2))}</pre>
`;
}
setActionStatus(dryRun ? "試跑完成" : "填寫完成", true);
await refreshConn();
} catch (err) {
document.getElementById("planView").innerHTML =
`<p class="settings-status err">${escapeHtml(err.message)}</p>`;
setActionStatus(err.message, false);
}
}
document.querySelectorAll('input[name="dateMode"]').forEach((el) => {
el.addEventListener("change", updateDateModeUI);
});
document.getElementById("setTodayBtn")?.addEventListener("click", setRangeToToday);
document.getElementById("previewBtn")?.addEventListener("click", () => runPreview().catch(console.error));
document.getElementById("fillBtn")?.addEventListener("click", () => runFill().catch(console.error));
document.getElementById("startDate")?.addEventListener("change", updateRangeMeta);
document.getElementById("endDate")?.addEventListener("change", updateRangeMeta);
document.getElementById("dateList")?.addEventListener("input", updateRangeMeta);
setDefaultDates();
updateDateModeUI();
refreshConn().catch(console.error);