-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
443 lines (393 loc) · 33.2 KB
/
index.html
File metadata and controls
443 lines (393 loc) · 33.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<title>QUẢN LÝ CÔNG VIỆC — Liquid Glass Pro</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark" />
<link rel="manifest" href="/manifest.json">
<style>
:root{
--bg0: #0e1116;
--bg1: #131a24;
--glass: rgba(255,255,255,.08);
--glass-light: rgba(255,255,255,.35);
--glass-dark: rgba(20,20,20,.45);
--primary: #3aa0ff;
--primary-2: #007bff;
--accent: #ffc107;
--gold: #f6cf6a;
--warm-blue: #7ab8ff;
--success: #28a745;
--danger: #dc3545;
--info: #17a2b8;
--shadow-outer: 0 10px 30px rgba(0,0,0,.35), 0 1px 0 rgba(255,255,255,.06) inset;
--shadow-inner: inset 0 2px 20px rgba(255,255,255,.12), inset 0 -6px 25px rgba(0,0,0,.25);
--bezier: cubic-bezier(.22,.61,.36,1);
}
*{box-sizing:border-box;transition: background-color .35s var(--bezier), color .35s var(--bezier),
box-shadow .5s var(--bezier), transform .45s var(--bezier), border-color .35s var(--bezier),
opacity .35s var(--bezier), filter .45s var(--bezier)}
/* ================= 1) GRADIENT BACKGROUND động ================= */
body{
margin:0; padding:18px; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color:#111;
background: radial-gradient(1200px 800px at 10% 0%, #e8f0ff 0%, #fff 30%, #f3f7ff 60%, #f7fbff 100%);
min-height:100svh; overflow-x:hidden; position:relative;
}
/* animated gradient layer */
.bg-anim{
position:fixed; inset:-30% -30%;
background: conic-gradient(from 180deg at 50% 50%, rgba(70,170,255,.25), rgba(255,170,70,.18), rgba(120,70,255,.22), rgba(70,170,255,.25));
filter: blur(80px) saturate(120%);
animation: swirl 22s linear infinite;
z-index:-2;
}
@keyframes swirl{ to{ transform: rotate(360deg) scale(1.15);} }
/* blur overlay depth */
.bg-blur{
position:fixed; inset:0; backdrop-filter: blur(12px) saturate(115%); -webkit-backdrop-filter: blur(12px) saturate(115%);
z-index:-1; pointer-events:none;
}
/* Dark mode gradient sang trọng */
body.dark-mode{
color:#f4f6fb;
background: radial-gradient(1200px 800px at 15% -10%, #10151d 0%, #0b0e14 40%, #0a0d12 100%);
}
body.dark-mode .bg-anim{ background: conic-gradient(from 0deg at 50% 50%, rgba(28,124,255,.22), rgba(249,198,92,.22), rgba(100,120,255,.2), rgba(28,124,255,.22)); filter: blur(90px) saturate(140%);}
/* ================= 2) GLASS EFFECT nâng cao ================= */
.glass{
position:relative; border-radius:22px;
background: linear-gradient(180deg, rgba(255,255,255,.5), rgba(255,255,255,.28)) ;
border:1px solid rgba(255,255,255,.35);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow: var(--shadow-outer), var(--shadow-inner);
}
body.dark-mode .glass{
background: linear-gradient(180deg, rgba(22,22,22,.5), rgba(18,18,18,.35));
border:1px solid rgba(255,255,255,.06);
}
/* Border highlight chạy viền */
.glass::after{
content:""; position:absolute; inset:0; border-radius:inherit; padding:1px;
background: linear-gradient(135deg, rgba(255,255,255,.65), rgba(255,255,255,0) 30%, rgba(255,255,255,.25) 70%, rgba(255,255,255,0));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor; mask-composite: exclude; pointer-events:none; opacity:.9;
}
/* ================= 3) Animations tinh tế ================= */
.hover-lift{ transform: translateY(0); }
.hover-lift:hover{ transform: translateY(-4px) scale(1.01); }
/* Shimmer for buttons */
.shimmer{ position:relative; overflow:hidden; }
.shimmer::before{
content:""; position:absolute; inset:-200% -50% auto -50%; height:200%; width:30%; transform: skewX(-25deg);
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,.55) 50%, rgba(255,255,255,0) 100%);
animation: shimmer 2.4s var(--bezier) infinite;
}
@keyframes shimmer{ 0%{left:-60%} 100%{left:130%} }
/* Ripple effect */
.ripple{ position:relative; overflow:hidden; }
.ripple span.r{ position:absolute; border-radius:50%; transform:scale(0); animation:rip 600ms var(--bezier); background:rgba(255,255,255,.55); pointer-events:none }
@keyframes rip{ to{ transform:scale(12); opacity:0 } }
/* Pin pulse */
.pin-icon{ animation: pinPulse 2.2s ease-in-out infinite; transform-origin: center; }
@keyframes pinPulse{ 0%,100%{ transform: scale(1); filter: drop-shadow(0 0 6px rgba(255,193,7,.55));} 50%{ transform: scale(1.12);} }
/* Modal fade/slide */
.modal{ display:none; position:fixed; inset:0; z-index:100; background:rgba(0,0,0,.45); opacity:0 }
.modal.show{ display:block; animation: modalFade .35s var(--bezier) forwards; }
@keyframes modalFade{ from{ opacity:0 } to{ opacity:1 } }
.modal-content{ width:min(560px,92vw); margin:8svh auto; padding:22px }
.modal-content.animate{ animation: slideUp .35s var(--bezier) }
@keyframes slideUp{ from{ transform: translateY(18px); opacity:.7 } to{ transform:none; opacity:1 } }
/* Toast */
.toast{ position:fixed; left:50%; bottom: -80px; transform: translateX(-50%); padding:12px 16px; border-radius:14px; font-weight:600; z-index:1200; pointer-events:none }
.toast.show{ bottom:24px; animation: toastIn .4s var(--bezier); }
@keyframes toastIn{ from{ bottom:-80px; opacity:0 } to{ bottom:24px; opacity:1 } }
/* ================= 4) Typography & Colors ================= */
.title{ font-size: clamp(24px, 3.2vw, 40px); font-weight:800; letter-spacing:.2px; margin:0; background:linear-gradient(90deg, #007bff, #5ca1ff, #9ad2ff); -webkit-background-clip:text; background-clip:text; color:transparent }
body.dark-mode .title{ background: linear-gradient(90deg, var(--gold), #ffd77a, #fff2c1); }
.subtle{ color: #657289 }
body.dark-mode .subtle{ color:#a5b3c9 }
/* Gradient buttons w/ multi shadows */
.btn{ border:none; border-radius:16px; padding:12px 16px; font-weight:700; font-size:16px; cursor:pointer; color:#fff; box-shadow: 0 10px 28px rgba(0,123,255,.28), 0 1px 0 rgba(255,255,255,.35) inset; }
.btn-primary{ background: linear-gradient(135deg, #3aa0ff, #007bff 55%, #0056c9) }
.btn-warm{ background: linear-gradient(135deg, #ffdd87, #ffc107 55%, #f6b83b); color:#2c2100; box-shadow: 0 10px 28px rgba(255,193,7,.32), 0 1px 0 rgba(255,255,255,.45) inset }
.btn-danger{ background: linear-gradient(135deg, #ff7a7a, #dc3545) }
.btn-info{ background: linear-gradient(135deg, #6be4ff, #17a2b8) }
.btn-ghost{ background: transparent; color:inherit; border:1px solid rgba(255,255,255,.35) }
/* Focus glow */
input, select, textarea{ width:100%; padding:14px 14px; border-radius:14px; border:1px solid rgba(0,0,0,.06); background: rgba(255,255,255,.7); box-shadow: inset 0 1px 3px rgba(0,0,0,.06) }
input:focus, select:focus, textarea:focus{ outline:none; box-shadow: 0 0 0 4px rgba(0,123,255,.22), inset 0 1px 3px rgba(0,0,0,.06); background:#fff }
body.dark-mode input, body.dark-mode select, body.dark-mode textarea{ background: rgba(40,40,40,.85); border-color: rgba(255,255,255,.06); color:#f1f3f6 }
body.dark-mode input:focus, body.dark-mode select:focus, body.dark-mode textarea:focus{ box-shadow: 0 0 0 4px rgba(255,193,7,.28) }
/* ================= Layout & Existing Sections ================= */
.container{ max-width:1100px; margin:0 auto }
.header{ display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px }
.header-tools{ display:flex; gap:8px; flex-wrap:wrap }
.control-bar{ display:flex; gap:10px; flex-wrap:wrap; margin:14px 0 }
.form-container{ padding:18px; margin-bottom:22px }
.stats-container{ padding:18px; margin-top:18px }
.input-row{ display:grid; grid-template-columns:repeat(2, 1fr); gap:12px}
@media (min-width:820px){ .input-row{ grid-template-columns:repeat(3, 1fr)} }
@media (max-width:640px){ .input-row{ grid-template-columns:1fr } }
.item-list{ list-style:none; padding:0; margin:0 }
.item{ position:relative; padding:18px; margin:0 0 14px; cursor:pointer }
.item:hover{ box-shadow: 0 18px 36px rgba(0,0,0,.15) }
.item.completed{ opacity:.65 }
.item.completed .item-title{text-decoration: line-through}
.item-title{ font-size:18px; font-weight:700; line-height:1.4; margin-bottom:6px }
.item-meta{ font-size:13px; color:#5f6b7a; margin-top:6px; padding-left:8px; border-left:2px solid #cbd5e1 }
body.dark-mode .item-meta{ color:#cbd5e1; border-left:2px solid #455165 }
.tag{ background:#007bff; color:#fff; padding:4px 10px; border-radius:999px; font-size:12px; font-weight:600; margin-right:6px; margin-top:6px; display:inline-block }
body.dark-mode .tag{ background:#ffc107; color:#1a1a1a }
.filter-buttons{ display:flex; gap:8px; flex-wrap:wrap; padding:10px; border-radius:16px }
.filter-buttons .btn{ padding:10px 14px }
/* Search icon scale animation */
.search-container{ position:relative; margin:12px 0 18px }
.search-input{ padding-right:44px }
.search-icon{ position:absolute; right:14px; top:50%; transform: translateY(-50%) scale(1); font-size:18px; transition: transform .25s var(--bezier) }
.search-input:focus + .search-icon{ transform: translateY(-50%) scale(1.18) }
/* ================= 5) Stats Cards ================= */
.stats-grid{ display:grid; grid-template-columns: repeat(2,1fr); gap:12px }
@media (min-width:820px){ .stats-grid{ grid-template-columns: repeat(4,1fr) } }
.stat-item{ padding:16px; text-align:center }
.stat-number{ font-size: clamp(22px, 4.2vw, 36px); font-weight:800; background: linear-gradient(90deg, #007bff, #5ca1ff, #9ad2ff); -webkit-background-clip:text; background-clip:text; color:transparent }
body.dark-mode .stat-number{ background: linear-gradient(90deg, #ffd77a, #ffe6a6, #fff2c1) }
.stat-label{ opacity:.8; font-weight:600 }
/* ================= 6) Interactive Elements ================= */
.item-actions{ display:grid; grid-template-columns: repeat(4,1fr); gap:8px; margin-top:14px }
/* ================= 7) Dark Mode accents ================= */
.dark-mode-toggle{ border:none; border-radius:14px; padding:10px 14px; font-weight:700; cursor:pointer }
.dark-mode-toggle.primary{ background: linear-gradient(135deg, var(--warm-blue), var(--primary)); color:#051326 }
.dark-mode-toggle.warm{ background: linear-gradient(135deg, #fff0b0, var(--gold)); color:#2c2100 }
/* Utility */
.center{ text-align:center }
</style>
</head>
<body>
<!-- Animated background layers -->
<div class="bg-anim" aria-hidden="true"></div>
<div class="bg-blur" aria-hidden="true"></div>
<div class="container">
<div class="header">
<h1 class="title">📋 Quản Lý Công Việc</h1>
<button class="dark-mode-toggle warm ripple shimmer" id="darkToggle">☀️/🌙</button>
</div>
<div class="header-tools" style="align-items:center">
<button class="btn btn-primary ripple shimmer" onclick="connectDataFile()">🔗 Kết nối tệp</button>
<button class="btn btn-info ripple" onclick="changeDataFile()">📂 Chọn tệp</button>
<button class="btn btn-warm ripple" onclick="manualSave()">💾 Lưu ngay</button>
<button class="btn btn-ghost ripple" onclick="reloadFromFile()">↩️ Tải lại</button>
<button class="btn btn-ghost ripple" onclick="exportData()">⬇️ Xuất</button>
<input type="file" id="importFile" accept=".json" style="display:none" onchange="handleImportFile(this, 'merge')">
<button class="btn btn-ghost ripple" onclick="document.getElementById('importFile').click()">⬆️ Nhập (Hợp nhất)</button>
<span id="dataFileLabel" class="subtle"></span>
</div>
<div id="tasksTab" class="tab-content active">
<div class="form-container glass hover-lift">
<div class="input-group">
<input type="text" id="taskInput" placeholder="Nội dung công việc">
</div>
<div class="input-row">
<input type="datetime-local" id="receiveTime" placeholder="Thời gian nhận công việc">
<input type="date" id="endTime" placeholder="Hạn hoàn thành">
<select id="prioritySelect">
<option value="low">Độ ưu tiên: Thấp</option>
<option value="medium" selected>Độ ưu tiên: Trung bình</option>
<option value="high">Độ ưu tiên: Cao</option>
</select>
</div>
<div class="input-row">
<input type="text" id="assignedToInput" placeholder="👤 Giao việc cho ai (người thực hiện)">
<input type="text" id="taskTags" placeholder="🏷️ Thẻ (cách nhau bởi dấu phẩy)">
</div>
<button class="btn btn-primary ripple shimmer" onclick="addTask()">➕ THÊM CÔNG VIỆC</button>
</div>
<div class="search-container">
<input type="text" class="search-input" id="taskSearch" placeholder="🔍 Tìm kiếm công việc..." onkeyup="searchTasks()">
<span class="search-icon">🔍</span>
</div>
<div class="filter-buttons glass">
<button class="btn btn-ghost ripple" data-filter="all" onclick="setTaskFilter('all'); setActiveFilter(this)">Tất cả</button>
<button class="btn btn-ghost ripple" data-filter="active" onclick="setTaskFilter('active'); setActiveFilter(this)">Chưa hoàn thành</button>
<button class="btn btn-ghost ripple" data-filter="completed" onclick="setTaskFilter('completed'); setActiveFilter(this)">Đã hoàn thành</button>
<button class="btn btn-ghost ripple" data-filter="pinned" onclick="setTaskFilter('pinned'); setActiveFilter(this)">Đã ghim</button>
</div>
<ul id="taskList" class="item-list"></ul>
<div class="stats-container glass hover-lift">
<h3 style="margin-top:0; font-weight:800">📊 Thống kê Công việc</h3>
<div class="stats-grid">
<div class="stat-item glass">
<div class="stat-number" id="totalTasks">0</div>
<div class="stat-label">Tổng cộng</div>
</div>
<div class="stat-item glass">
<div class="stat-number" id="completedTasks">0</div>
<div class="stat-label">Hoàn thành</div>
</div>
<div class="stat-item glass">
<div class="stat-number" id="activeTasks">0</div>
<div class="stat-label">Đang chờ</div>
</div>
<div class="stat-item glass">
<div class="stat-number" id="pinnedTasks">0</div>
<div class="stat-label">Đã ghim</div>
</div>
</div>
</div>
</div>
<!-- Modal Sửa -->
<div id="editTaskModal" class="modal" role="dialog" aria-modal="true">
<div class="modal-content glass animate">
<span class="close" onclick="closeEditTaskModal()" style="float:right; font-size:26px; cursor:pointer">×</span>
<h2 style="margin-top:0; font-weight:800">✏️ Sửa Công việc</h2>
<div class="input-group"><input type="text" id="editTaskInput" placeholder="Nội dung công việc"></div>
<div class="input-row">
<input type="datetime-local" id="editReceiveTime">
<input type="date" id="editEndTime">
<select id="editPrioritySelect">
<option value="low">Thấp</option>
<option value="medium">Trung bình</option>
<option value="high">Cao</option>
</select>
</div>
<div class="input-row">
<input type="text" id="editAssignedToInput" placeholder="👤 Giao việc cho ai (người thực hiện)">
</div>
<div class="input-row">
<input type="text" id="editTaskTags" placeholder="Thẻ (cách nhau bởi dấu phẩy)">
</div>
<div class="input-group">
<label for="editHistory" style="font-weight:700; margin-bottom:6px; display:block">Lịch sử Sửa/Chuyển giao:</label>
<textarea id="editHistory" placeholder="Lịch sử sửa chữa, chuyển giao công việc..."></textarea>
</div>
<button class="btn btn-warm ripple" onclick="saveEditTask()">💾 LƯU THAY ĐỔI</button>
</div>
</div>
<div id="toast" class="toast glass"></div>
</div>
<script>
/* ========= Keep app state from original ========= */
let tasks = []; let currentTaskFilter = 'all'; let editingTaskId = null; let dataFileHandle = null; let savingQueued = false;
// Utility: gradient date display
function formatDateDisplay(datetimeString, includeTime = true) {
if (!datetimeString) return 'N/A';
try {
const date = new Date(datetimeString);
const dateStr = date.toLocaleDateString('vi-VN');
if (!includeTime) return dateStr;
const timeStr = date.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' });
return `${timeStr} ${dateStr}`;
} catch { return datetimeString; }
}
/* ========= 3) Toast slide-up ========= */
function showToast(message, type = 'success'){
const el = document.getElementById('toast');
el.textContent = message; el.className = `toast glass show ${type}`;
setTimeout(()=> { el.classList.remove('show'); }, 3200);
}
/* ========= Dark Mode ========= */
function toggleDarkMode(){ document.body.classList.toggle('dark-mode'); localStorage.setItem('darkMode', document.body.classList.contains('dark-mode')); }
/* ========= Ripple handler ========= */
function attachRipple(root=document){
root.querySelectorAll('.ripple').forEach(btn=>{
btn.addEventListener('click', function(e){
const r = document.createElement('span'); r.className='r';
const rect = this.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
r.style.width = r.style.height = size + 'px';
r.style.left = (e.clientX - rect.left - size/2) + 'px';
r.style.top = (e.clientY - rect.top - size/2) + 'px';
this.appendChild(r); setTimeout(()=> r.remove(), 650);
});
});
}
/* ========= Storage ========= */
function loadData(){ const stored = localStorage.getItem('todoAppData'); if (stored){ const data = JSON.parse(stored); tasks = data.tasks || []; } }
function saveData(){ localStorage.setItem('todoAppData', JSON.stringify({ tasks, lastSaved: new Date().toISOString() })); }
/* ========= Render tasks ========= */
function renderTasks(){
const list = document.getElementById('taskList'); list.innerHTML = '';
let filtered = tasks.filter(task=>{ if (currentTaskFilter==='active') return !task.completed; if (currentTaskFilter==='completed') return task.completed; if (currentTaskFilter==='pinned') return task.pinned; return true;});
const search = document.getElementById('taskSearch')?.value.toLowerCase();
if (search){ filtered = filtered.filter(t => t.text.toLowerCase().includes(search) || t.tags.some(tag => tag.toLowerCase().includes(search)) || t.assignedTo?.toLowerCase().includes(search)); }
filtered.sort((a,b)=> b.pinned - a.pinned || new Date(b.createdAt) - new Date(a.createdAt));
if (!filtered.length){ list.innerHTML = '<p class="subtle center">Không có công việc nào.</p>'; return; }
for (const task of filtered){
const li = document.createElement('li'); li.className = 'item glass hover-lift task-item'; if (task.completed) li.classList.add('completed'); if (task.pinned) li.classList.add('pinned');
const tagsHtml = task.tags.map(tag=>`<span class="tag">${tag}</span>`).join('');
const completedTimeHtml = task.completed && task.completedTime ? `<div class="item-meta" style="color:#28a745; font-weight:700">✅ Hoàn thành: ${formatDateDisplay(task.completedTime, true)}</div>` : '';
const assignMeta = task.assignedTo ? `<div class="item-meta">👤 <strong>Giao cho</strong>: ${task.assignedTo}</div>` : '';
const receiveMeta = task.receiveTime ? `<div class="item-meta">🕒 <strong>Thời gian nhận</strong>: ${formatDateDisplay(task.receiveTime)}</div>` : '';
const deadlineMeta = task.endTime ? `<div class="item-meta">📅 <strong>Hạn hoàn thành</strong>: ${formatDateDisplay(task.endTime, false)}</div>` : '';
const historyHtml = (task.editHistory && task.editHistory.length>0) ? `<div class="item-meta" style="margin-top:10px">📜 <strong>Lịch sử sửa/chuyển giao</strong>:<br/>${task.editHistory.map(h=>`<span style="display:block; margin-left:10px; font-size:12px; opacity:.8">- ${h.time}: ${h.note}</span>`).join('')}</div>` : '';
li.innerHTML = `${task.pinned ? '<div class="pin-icon">📌</div>' : ''}
<div class="item-title">${task.text}</div>
${tagsHtml?`<div class="item-tags">${tagsHtml}</div>`:''}
${receiveMeta}${deadlineMeta}${assignMeta}${historyHtml}
<div class="item-meta"><span>✨ Độ ưu tiên: ${task.priority[0].toUpperCase()+task.priority.slice(1)}</span> <span>| Tạo: ${formatDateDisplay(task.createdAt,false)}</span></div>
${completedTimeHtml}
<div class="item-actions">
<button class="btn btn-info ripple" onclick="event.stopPropagation(); openEditTaskModal(${task.id})">✏️ Sửa</button>
<button class="btn btn-warm ripple" onclick="event.stopPropagation(); toggleTaskPin(${task.id})">${task.pinned? '📌 Bỏ ghim':'📌 Ghim'}</button>
<button class="btn btn-danger ripple" onclick="event.stopPropagation(); deleteTask(${task.id})">🗑️ Xóa</button>
<button class="btn ripple" style="background:${task.completed ? '#dc3545' : '#28a745'}" onclick="event.stopPropagation(); toggleTaskComplete(${task.id})">${task.completed ? '↩️ Hoàn tác' : '✅ Hoàn thành'}</button>
</div>`;
list.appendChild(li);
}
attachRipple(list);
}
function addTask(){
const text = document.getElementById('taskInput').value.trim(); const receive = document.getElementById('receiveTime').value; const end = document.getElementById('endTime').value; const priority = document.getElementById('prioritySelect').value; const tagsStr = document.getElementById('taskTags').value.trim(); const assignedTo = document.getElementById('assignedToInput').value.trim();
if (!text) return showToast('Vui lòng nhập nội dung công việc!','error');
const task = { id: Date.now(), text, receiveTime: receive, endTime: end, priority, tags: tagsStr? tagsStr.split(',').map(t=>t.trim()).filter(Boolean):[], completed:false, completedTime:null, pinned:false, assignedTo, editHistory:[], createdAt: new Date().toISOString() };
tasks.unshift(task); saveData(); renderTasks(); updateTaskStats();
document.getElementById('taskInput').value=''; document.getElementById('receiveTime').value=''; document.getElementById('endTime').value=''; document.getElementById('prioritySelect').value='medium'; document.getElementById('taskTags').value=''; document.getElementById('assignedToInput').value='';
showToast('Đã thêm công việc!');
}
function toggleTaskComplete(id){ const task = tasks.find(t=>t.id===id); if (!task) return; task.completed = !task.completed; task.completedTime = task.completed? new Date().toISOString(): null; if (task.completed) task.pinned = false; saveData(); renderTasks(); updateTaskStats(); showToast(task.completed? 'Công việc đã hoàn thành!':'Đã hoàn tác công việc!'); }
function deleteTask(id){ if (!confirm('Bạn có chắc chắn muốn xóa công việc này không?')) return; tasks = tasks.filter(t=>t.id!==id); saveData(); renderTasks(); updateTaskStats(); showToast('Đã xóa công việc!'); }
function toggleTaskPin(id){ const task = tasks.find(t=>t.id===id); if (!task) return; task.pinned = !task.pinned; saveData(); renderTasks(); updateTaskStats(); showToast(task.pinned? 'Đã ghim công việc!':'Đã bỏ ghim công việc!'); }
function setTaskFilter(filter){ currentTaskFilter = filter; renderTasks(); }
function setActiveFilter(btn){ document.querySelectorAll('.filter-buttons .btn').forEach(b=>b.classList.remove('btn-primary')); btn.classList.add('btn-primary'); }
function searchTasks(){ renderTasks(); }
function updateTaskStats(){ document.getElementById('totalTasks').textContent = tasks.length; document.getElementById('completedTasks').textContent = tasks.filter(t=>t.completed).length; document.getElementById('activeTasks').textContent = tasks.filter(t=>!t.completed).length; document.getElementById('pinnedTasks').textContent = tasks.filter(t=>t.pinned).length; }
/* ========= Modal ========= */
function openEditTaskModal(id){ editingTaskId = id; const task = tasks.find(t=>t.id===id); if (!task) return; document.getElementById('editTaskInput').value = task.text; const receiveTimeValue = task.receiveTime ? task.receiveTime.substring(0,16) : ''; document.getElementById('editReceiveTime').value = receiveTimeValue; document.getElementById('editEndTime').value = task.endTime; document.getElementById('editPrioritySelect').value = task.priority; document.getElementById('editTaskTags').value = task.tags.join(', '); document.getElementById('editAssignedToInput').value = task.assignedTo || ''; let historyText=''; if (task.editHistory?.length){ historyText = task.editHistory.map(h=>`${h.time}: ${h.note}`).join('\n'); } document.getElementById('editHistory').value = historyText; const m = document.getElementById('editTaskModal'); m.classList.add('show'); }
function closeEditTaskModal(){ const m = document.getElementById('editTaskModal'); m.classList.remove('show'); editingTaskId = null; }
function saveEditTask(){ const task = tasks.find(t=>t.id===editingTaskId); if (!task) return; const oldAssignedTo = task.assignedTo; const newAssignedTo = document.getElementById('editAssignedToInput').value.trim(); const historyNote = document.getElementById('editHistory').value.trim(); const newReceiveTime = document.getElementById('editReceiveTime').value; task.text = document.getElementById('editTaskInput').value.trim(); task.receiveTime = newReceiveTime; task.endTime = document.getElementById('editEndTime').value; task.priority = document.getElementById('editPrioritySelect').value; task.tags = document.getElementById('editTaskTags').value.trim().split(',').map(t=>t.trim()).filter(Boolean); task.assignedTo = newAssignedTo; const newHistory=[]; if (historyNote){ const lines = historyNote.split('\n').filter(l=>l.trim()!==''); for (const line of lines){ const parts = line.split(':'); if (parts.length>=2){ newHistory.push({ time: parts[0].trim(), note: parts.slice(1).join(':').trim() }); } } }
if (oldAssignedTo !== newAssignedTo){ const timeStr = new Date().toLocaleTimeString('vi-VN') + ' ' + new Date().toLocaleDateString('vi-VN'); const note = `Chuyển giao từ **${oldAssignedTo || 'N/A'}** sang **${newAssignedTo || 'N/A'}**`; newHistory.unshift({ time: timeStr, note }); }
task.editHistory = newHistory; saveData(); renderTasks(); updateTaskStats(); showToast('Đã cập nhật công việc!'); closeEditTaskModal(); }
/* ========= File Access (kept) ========= */
async function idbGet(key){ const stored = localStorage.getItem('idb:'+key); return stored ? JSON.parse(stored) : null; }
async function idbSet(key, value){ localStorage.setItem('idb:'+key, JSON.stringify(value)); }
function showFileLabel(name){ document.getElementById('dataFileLabel').textContent = `Tệp hiện tại: ${name}`; }
async function resolveHandleName(handle){ try{ return handle.name }catch{ return 'Tệp không xác định' } }
async function requestWritePermission(handle){ const opts = { mode:'readwrite' }; try{ const st = await handle.queryPermission(opts); if (st==='granted') return true; const st2 = await handle.requestPermission(opts); return st2 === 'granted'; }catch{ return false } }
async function saveAllToFile(){ if (!dataFileHandle) return; if (!(await requestWritePermission(dataFileHandle))){ showToast('Không có quyền ghi vào tệp!','error'); return; } try{ const writable = await dataFileHandle.createWritable(); const data = { tasks: window.tasks, lastSaved: new Date().toISOString() }; await writable.write(JSON.stringify(data, null, 2)); await writable.close(); showToast('Đã lưu vào tệp!'); }catch(e){ console.error('Lỗi khi ghi tệp:', e); showToast('Lỗi lưu tệp: '+e.message, 'error'); } }
async function loadFromCurrentFile(){ if (!dataFileHandle) return; try{ const file = await dataFileHandle.getFile(); const text = await file.text(); importDataText(text, 'overwrite'); showToast('Đã tải lại dữ liệu từ tệp.'); }catch(e){ console.error('Lỗi khi đọc tệp:', e); showToast('Lỗi đọc tệp: '+e.message, 'error'); } }
async function manualSave(){ await saveAllToFile(); }
async function reloadFromFile(){ await loadFromCurrentFile(); }
function exportData(){ try{ const stored = localStorage.getItem('todoAppData'); const obj = stored ? JSON.parse(stored) : { tasks: window.tasks, lastSaved: new Date().toISOString() }; const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const ts = new Date().toISOString().replace(/[:.]/g,'-'); a.href = url; a.download = `congviec-backup-${ts}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast('Đã xuất dữ liệu ra file JSON!'); }catch(e){ console.error(e); showToast('Xuất dữ liệu thất bại!','error'); } }
function handleImportFile(input, mode){ const file = input.files && input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = e => { try{ const text = e.target.result; importDataText(text, mode); }catch(err){ console.error(err); showToast('Không thể đọc file!','error'); }finally{ input.value='' } }; reader.readAsText(file); }
function importDataText(text, mode){ try{ const incomingData = JSON.parse(text); const incomingTasks = incomingData.tasks || []; if (mode==='overwrite'){ tasks = incomingTasks; } else { const taskMap = new Map(tasks.map(t=>[t.id,t])); for (const t of incomingTasks){ const existing = taskMap.get(t.id); if (existing){ const curDate = new Date(existing.createdAt||0); const incDate = new Date(t.createdAt||0); if (incDate > curDate) taskMap.set(t.id, t); } else { taskMap.set(t.id, t); } } tasks = Array.from(taskMap.values()); } tasks.sort((a,b)=> new Date(b.createdAt)-new Date(a.createdAt)); saveData(); renderTasks(); updateTaskStats(); showToast('Đã nhập dữ liệu thành công!'); }catch(e){ console.error('Invalid JSON', e); showToast('Nội dung tệp không đúng định dạng.','error'); } }
async function connectDataFile(){ try{ const handle = await window.showSaveFilePicker({ suggestedName:'congviec.json', types:[{ description:'JSON', accept:{ 'application/json':['.json'] } }] }); if (!(await requestWritePermission(handle))){ showToast('Chưa có quyền ghi vào tệp đã chọn.','error'); return; } dataFileHandle = handle; await idbSet('dataFileHandle', handle); await saveAllToFile(); showFileLabel(await resolveHandleName(handle)); showToast('Đã kết nối & lưu dữ liệu vào tệp.'); }catch(e){ console.error(e); } }
async function changeDataFile(){ try{ const [handle] = await window.showOpenFilePicker({ multiple:false, types:[{ description:'JSON', accept:{ 'application/json':['.json'] } }] }); if (!(await requestWritePermission(handle))){ showToast('Chưa có quyền ghi vào tệp đã chọn.','error'); return; } dataFileHandle = handle; await idbSet('dataFileHandle', handle); showFileLabel(await resolveHandleName(handle)); await loadFromCurrentFile(); }catch(e){ console.error(e); } }
(function interceptLocalStorage(){ const orig = Storage.prototype.setItem; Storage.prototype.setItem = function(key, value){ const result = orig.apply(this, arguments); if (key==='todoAppData'){ if (!savingQueued){ savingQueued = true; setTimeout(async()=>{ try{ await saveAllToFile(); } finally { savingQueued=false; } }, 700); } } return result; } })();
window.addEventListener('DOMContentLoaded', async ()=>{
// Restore data & dark mode
loadData(); renderTasks(); updateTaskStats(); attachRipple();
if (localStorage.getItem('darkMode')==='true') document.body.classList.add('dark-mode');
// Filter default active style
const firstFilterBtn = document.querySelector('.filter-buttons .btn[data-filter="all"]'); if (firstFilterBtn) firstFilterBtn.classList.add('btn-primary');
// Dark toggle
document.getElementById('darkToggle').addEventListener('click', toggleDarkMode);
// Load file handle
try{ const handle = await idbGet('dataFileHandle'); if (handle){ dataFileHandle = handle; showFileLabel(await resolveHandleName(handle)); await requestWritePermission(handle); await loadFromCurrentFile(); } else { showFileLabel('Chưa kết nối tệp.'); } }catch(e){ console.error('Error loading file handle', e); showFileLabel('Lỗi tải tệp tin.'); }
});
// Close modal when click outside
window.addEventListener('click', (e)=>{ const m = document.getElementById('editTaskModal'); if (e.target===m) closeEditTaskModal(); });
</script>
</body>
</html>