/* core-v31.kho.js: GOP cho trang ADMIN CHAM BAI (core-v31) = student-work.css v18.5.1 + student-work.js a6ad656 (nhung nguyen van thanh chuoi) + ma khoi dong cua trang (nguyen van, chi doi 2 cho fetchText(CORE_URL,false) thanh chuoi nhung san). Khi sua student-work.js hoac student-work.css thi phai gop lai file nay. */
(function(){var s=document.createElement("style");s.setAttribute("data-kho","student-work.css");s.textContent="\n/* =========================================================\n TÁC PHẨM HỌC VIÊN V18.4D\n RELIABLE FAST GRADING + ADMIN DELETE\n========================================================= */\n\n.tp18-wrap{\n max-width:1240px;\n margin:0 auto;\n padding:24px 14px 50px;\n box-sizing:border-box;\n font-family:Arial,Helvetica,sans-serif;\n color:#172033;\n}\n\n.tp18-wrap.locked{\n display:none!important;\n}\n\n.tp18-header{\n text-align:center;\n margin-bottom:16px;\n}\n\n.tp18-header h2{\n margin:0;\n font-size:30px;\n font-weight:900;\n color:#172033;\n}\n\n.tp18-header p{\n margin:7px 0 0;\n font-size:12px;\n color:#718096;\n}\n\n.tp18-admin-note,\n.tp18-status{\n display:none;\n max-width:960px;\n margin:0 auto 14px;\n padding:10px 14px;\n box-sizing:border-box;\n border-radius:11px;\n text-align:center;\n font-size:10px;\n line-height:1.55;\n font-weight:800;\n}\n\n.tp18-admin-note.on{\n display:block;\n background:#f5f3ff;\n border:1px solid #c4b5fd;\n color:#6d28d9;\n}\n\n.tp18-status.on{\n display:block;\n background:#ecfdf5;\n color:#047857;\n}\n\n.tp18-status.warn{\n background:#fff7ed;\n color:#9a3412;\n}\n\n/* =========================================================\n TOAST\n========================================================= */\n\n.tp18-toast{\n position:fixed;\n z-index:9999999;\n left:50%;\n top:20px;\n transform:translateX(-50%) translateY(-15px);\n max-width:calc(100vw - 28px);\n min-width:220px;\n padding:12px 18px;\n border-radius:12px;\n box-shadow:0 12px 35px rgba(15,23,42,.23);\n color:#fff;\n font-size:12px;\n line-height:1.5;\n font-weight:900;\n text-align:center;\n opacity:0;\n visibility:hidden;\n pointer-events:none;\n transition:\n opacity .2s ease,\n transform .2s ease,\n visibility .2s ease;\n}\n\n.tp18-toast.on{\n opacity:1;\n visibility:visible;\n transform:translateX(-50%) translateY(0);\n}\n\n.tp18-toast.success{background:#059669;}\n.tp18-toast.error{background:#dc2626;}\n.tp18-toast.wait{background:#2563eb;}\n.tp18-toast.warning{background:#d97706;}\n\n/* =========================================================\n NỘP BÀI\n========================================================= */\n\n.tp18-submit{\n text-align:center;\n margin:0 0 24px;\n}\n\n.tp18-submit.hidden{\n display:none!important;\n}\n\n.tp18-submit a{\n display:inline-flex;\n align-items:center;\n justify-content:center;\n padding:12px 24px;\n border-radius:10px;\n background:#172033;\n color:#fff!important;\n text-decoration:none!important;\n font-size:14px;\n font-weight:900;\n box-shadow:0 7px 20px rgba(23,32,51,.13);\n}\n\n/* =========================================================\n ADMIN SEARCH\n========================================================= */\n\n.tp18-admin-search{\n display:none;\n max-width:960px;\n margin:0 auto 22px;\n}\n\n.tp18-admin-search.on{\n display:block;\n}\n\n.tp18-search-box{\n position:relative;\n display:flex;\n align-items:center;\n gap:9px;\n padding:9px;\n border:1px solid #ddd6fe;\n border-radius:16px;\n background:#fff;\n box-shadow:0 8px 26px rgba(91,33,182,.08);\n}\n\n.tp18-search-icon{\n width:42px;\n height:42px;\n flex:0 0 42px;\n display:flex;\n align-items:center;\n justify-content:center;\n border-radius:11px;\n background:#f5f3ff;\n color:#7c3aed;\n font-size:20px;\n}\n\n.tp18-search-input{\n min-width:0;\n flex:1;\n height:42px;\n padding:0 5px;\n border:0;\n outline:0;\n background:transparent;\n color:#172033;\n font-size:14px;\n font-weight:700;\n}\n\n.tp18-search-input::placeholder{\n color:#94a3b8;\n font-weight:600;\n}\n\n.tp18-search-clear{\n display:none;\n flex:0 0 auto;\n min-height:38px;\n padding:0 13px;\n border:0;\n border-radius:9px;\n background:#f1f5f9;\n color:#475569;\n cursor:pointer;\n font-size:10px;\n font-weight:900;\n}\n\n.tp18-search-clear.on{\n display:block;\n}\n\n.tp18-search-info{\n min-height:18px;\n padding:7px 7px 0;\n color:#64748b;\n text-align:center;\n font-size:10px;\n line-height:1.5;\n font-weight:800;\n}\n\n.tp18-search-info.active{\n color:#6d28d9;\n}\n\n/* =========================================================\n STUDENT VIEW\n========================================================= */\n\n.tp18-student-bar{\n display:none;\n max-width:980px;\n margin:0 auto 20px;\n padding:12px 14px;\n box-sizing:border-box;\n border:1px solid #e2e8f0;\n border-radius:12px;\n background:#fff;\n align-items:center;\n justify-content:space-between;\n gap:12px;\n}\n\n.tp18-student-bar.on{\n display:flex;\n}\n\n/* =========================================================\n PAGINATION\n========================================================= */\n\n.tp18-pages{\n display:none;\n justify-content:center;\n align-items:center;\n flex-wrap:wrap;\n gap:6px;\n margin:16px 0 22px;\n}\n\n.tp18-pages.on{\n display:flex;\n}\n\n.tp18-page-info{\n width:100%;\n margin-bottom:2px;\n text-align:center;\n font-size:10px;\n font-weight:800;\n color:#64748b;\n}\n\n.tp18-page{\n min-width:36px;\n height:36px;\n padding:0 10px;\n border:1px solid #dbe1ea;\n border-radius:8px;\n background:#fff;\n color:#475569;\n cursor:pointer;\n font-weight:900;\n}\n\n.tp18-page.current{\n background:#7c3aed;\n border-color:#7c3aed;\n color:#fff;\n}\n\n.tp18-page:disabled{\n opacity:.4;\n cursor:not-allowed;\n}\n\n/* =========================================================\n GRID\n========================================================= */\n\n.tp18-grid{\n display:grid;\n grid-template-columns:repeat(3,minmax(0,1fr));\n gap:22px;\n}\n\n.tp18-card{\n position:relative;\n min-width:0;\n overflow:visible;\n border:1px solid #dbe0ea;\n border-radius:19px;\n background:#fff;\n box-shadow:0 8px 24px rgba(23,32,51,.08);\n content-visibility:auto;\n contain-intrinsic-size:720px;\n}\n\n.tp18-card.priority{\n border:2px solid #f59e0b;\n box-shadow:0 12px 30px rgba(245,158,11,.18);\n}\n\n.tp18-card.grade-pending{\n border-color:#60a5fa;\n}\n\n.tp18-card.grade-synced{\n border-color:#86efac;\n}\n\n.tp18-card.grade-warning{\n border-color:#f59e0b;\n}\n\n/* =========================================================\n DELETE\n========================================================= */\n\n.tp18-delete{\n position:absolute;\n z-index:150;\n top:11px;\n right:11px;\n width:40px;\n height:40px;\n padding:0;\n display:flex;\n align-items:center;\n justify-content:center;\n border:1px solid rgba(255,255,255,.95);\n border-radius:50%;\n background:rgba(185,28,28,.9);\n color:#fff;\n box-shadow:0 5px 15px rgba(15,23,42,.3);\n cursor:pointer;\n font-size:22px;\n font-weight:700;\n line-height:1;\n -webkit-tap-highlight-color:transparent;\n backdrop-filter:blur(5px);\n transition:.16s ease;\n}\n\n.tp18-delete:hover{\n background:#dc2626;\n transform:scale(1.06);\n}\n\n.tp18-delete:active{\n transform:scale(.92);\n}\n\n.tp18-delete:disabled{\n opacity:.6;\n cursor:wait;\n}\n\n.tp18-delete.pending{\n background:#d97706;\n animation:tp18DeletePulse 1s ease-in-out infinite;\n}\n\n.tp18-delete-result{\n position:absolute;\n z-index:155;\n top:59px;\n right:10px;\n display:none;\n max-width:205px;\n padding:7px 9px;\n border-radius:8px;\n background:rgba(15,23,42,.92);\n color:#fff;\n box-shadow:0 4px 12px rgba(15,23,42,.25);\n font-size:8px;\n line-height:1.45;\n font-weight:900;\n text-align:center;\n}\n\n.tp18-delete-result.on{\n display:block;\n}\n\n.tp18-delete-result.error{\n background:#b91c1c;\n}\n\n.tp18-card.delete-checking{\n box-shadow:\n 0 0 0 2px #f59e0b inset,\n 0 8px 24px rgba(23,32,51,.08);\n}\n\n/* =========================================================\n PRIORITY\n========================================================= */\n\n.tp18-priority-label{\n position:absolute;\n z-index:120;\n top:-12px;\n right:13px;\n max-width:220px;\n padding:6px 10px;\n border-radius:999px;\n background:#f59e0b;\n color:#fff;\n font-size:8px;\n line-height:1.3;\n font-weight:900;\n text-align:center;\n}\n\n/* =========================================================\n IMAGE\n========================================================= */\n\n.tp18-image-box{\n position:relative;\n height:292px;\n overflow:hidden;\n border-radius:18px 18px 0 0;\n background:#eef2f7;\n}\n\n.tp18-work-link,\n.tp18-work-image{\n display:block;\n width:100%;\n height:100%;\n}\n\n.tp18-work-image{\n object-fit:cover;\n}\n\n.tp18-number{\n position:absolute;\n z-index:30;\n top:11px;\n left:11px;\n min-width:31px;\n height:31px;\n padding:0 8px;\n display:flex;\n align-items:center;\n justify-content:center;\n border-radius:999px;\n background:rgba(15,23,42,.83);\n color:#fff;\n font-size:11px;\n font-weight:900;\n}\n\n.tp18-no-image{\n width:100%;\n height:100%;\n display:flex;\n flex-direction:column;\n align-items:center;\n justify-content:center;\n gap:7px;\n background:linear-gradient(145deg,#111827,#312e81);\n color:#fff;\n font-size:39px;\n}\n\n.tp18-no-image small{\n font-size:9px;\n opacity:.72;\n}\n\n/* =========================================================\n LINH THÚ\n========================================================= */\n\n.tp18-beasts{\n position:relative;\n z-index:50;\n display:flex;\n flex-wrap:wrap;\n gap:4px;\n padding:0 12px 0 15px;\n margin-top:-35px;\n margin-bottom:-5px;\n}\n\n.tp18-beast{\n position:relative;\n width:68px;\n height:68px;\n flex:0 0 68px;\n}\n\n.tp18-beast img{\n display:block;\n width:100%;\n height:100%;\n object-fit:contain;\n filter:drop-shadow(0 4px 5px rgba(0,0,0,.21));\n animation:tp18Float 3.5s ease-in-out infinite;\n}\n\n.tp18-beast.supreme img{\n filter:\n drop-shadow(0 4px 5px rgba(0,0,0,.2))\n drop-shadow(0 0 9px rgba(236,72,153,.78));\n}\n\n.tp18-beast.premium img{\n filter:\n drop-shadow(0 4px 5px rgba(0,0,0,.2))\n drop-shadow(0 0 8px rgba(245,158,11,.78));\n}\n\n.tp18-beast-fallback{\n width:100%;\n height:100%;\n display:flex;\n align-items:center;\n justify-content:center;\n border-radius:50%;\n background:rgba(255,255,255,.94);\n font-size:35px;\n filter:drop-shadow(0 3px 5px rgba(0,0,0,.16));\n}\n\n.tp18-tooltip{\n position:absolute;\n z-index:200;\n bottom:70px;\n left:50%;\n transform:translateX(-50%);\n min-width:155px;\n max-width:220px;\n padding:7px 9px;\n border-radius:8px;\n background:#111827;\n color:#fff;\n text-align:center;\n font-size:9px;\n line-height:1.45;\n opacity:0;\n visibility:hidden;\n pointer-events:none;\n}\n\n.tp18-beast:hover .tp18-tooltip{\n opacity:1;\n visibility:visible;\n}\n\n/* =========================================================\n INFO\n========================================================= */\n\n.tp18-info{\n padding:17px;\n}\n\n.tp18-card.has-beast .tp18-info{\n padding-top:30px;\n}\n\n.tp18-name{\n margin-bottom:9px;\n font-size:18px;\n font-weight:900;\n cursor:pointer;\n}\n\n.tp18-meta{\n display:flex;\n flex-wrap:wrap;\n gap:6px;\n margin-bottom:12px;\n}\n\n.tp18-chip{\n padding:6px 8px;\n border-radius:999px;\n background:#f1f5f9;\n color:#526076;\n font-size:9px;\n font-weight:800;\n}\n\n.tp18-chip.priority{\n background:#fff7ed;\n border:1px solid #fed7aa;\n color:#9a3412;\n}\n\n/* =========================================================\n PUBLIC\n========================================================= */\n\n.tp18-public-data{\n display:grid;\n grid-template-columns:1fr;\n gap:9px;\n margin-bottom:12px;\n}\n\n.tp18-public-box{\n padding:10px 11px;\n border-radius:10px;\n border:1px solid #e2e8f0;\n background:#f8fafc;\n}\n\n.tp18-public-label{\n margin-bottom:5px;\n color:#64748b;\n font-size:8px;\n font-weight:900;\n text-transform:uppercase;\n}\n\n.tp18-public-score{\n font-size:19px;\n font-weight:900;\n}\n\n.tp18-public-score.ungraded{\n color:#7c3aed;\n}\n\n.tp18-public-score.graded{\n color:#dc2626;\n}\n\n.tp18-public-comment{\n color:#334155;\n font-size:11px;\n line-height:1.55;\n white-space:pre-wrap;\n}\n\n/* =========================================================\n SCORE\n========================================================= */\n\n.tp18-score{\n margin-bottom:10px;\n padding:10px 12px;\n border:1px solid #f1dfbd;\n border-radius:11px;\n background:#fffaf0;\n}\n\n.tp18-score-row{\n display:flex;\n align-items:center;\n justify-content:space-between;\n gap:10px;\n}\n\n.tp18-score-label{\n color:#695736;\n font-size:11px;\n font-weight:800;\n}\n\n.tp18-score input{\n width:80px;\n padding:8px;\n box-sizing:border-box;\n border:1px solid #d7b56a;\n border-radius:7px;\n text-align:center;\n font-size:17px;\n font-weight:900;\n transition:.2s ease;\n}\n\n.tp18-score input.ungraded{\n color:#7c3aed;\n border-color:#c4b5fd;\n background:#faf5ff;\n}\n\n.tp18-score input.graded{\n color:#dc2626;\n border-color:#fca5a5;\n background:#fff7f7;\n}\n\n/* =========================================================\n COMMENT\n========================================================= */\n\n.tp18-comment{\n margin-bottom:12px;\n padding:11px;\n border:1px solid #dbe7f4;\n border-radius:11px;\n background:#f7fbff;\n}\n\n.tp18-comment-title{\n margin-bottom:6px;\n color:#35506f;\n font-size:9px;\n font-weight:900;\n text-transform:uppercase;\n}\n\n.tp18-comment textarea{\n width:100%;\n min-height:95px;\n padding:9px;\n box-sizing:border-box;\n border:1px solid #bfd2e7;\n border-radius:8px;\n resize:vertical;\n font:12px/1.5 Arial,sans-serif;\n}\n\n/* =========================================================\n GRADE SYNC\n========================================================= */\n\n.tp18-grade-result{\n display:none;\n margin:0 0 10px;\n padding:9px 10px;\n border-radius:8px;\n font-size:9px;\n line-height:1.5;\n font-weight:900;\n text-align:center;\n}\n\n.tp18-grade-result.wait{\n display:block;\n background:#eff6ff;\n color:#1d4ed8;\n}\n\n.tp18-grade-result.ok{\n display:block;\n background:#ecfdf5;\n color:#047857;\n}\n\n.tp18-grade-result.error{\n display:block;\n background:#fef2f2;\n color:#b91c1c;\n}\n\n.tp18-grade-result.warning{\n display:block;\n background:#fff7ed;\n color:#9a3412;\n}\n\n.tp18-sync-dot{\n display:inline-block;\n width:6px;\n height:6px;\n margin-right:5px;\n border-radius:50%;\n background:currentColor;\n vertical-align:middle;\n}\n\n.tp18-grade-result.wait .tp18-sync-dot{\n animation:tp18Pulse 1s ease-in-out infinite;\n}\n\n/* =========================================================\n OWNED GIFTS\n========================================================= */\n\n.tp18-gifts-owned{\n display:none;\n margin:9px 0 12px;\n padding:10px;\n border:1px solid #e9d5ff;\n border-radius:11px;\n background:#faf5ff;\n}\n\n.tp18-gifts-owned.on{\n display:block;\n}\n\n.tp18-gifts-owned-title{\n margin-bottom:7px;\n color:#7e22ce;\n font-size:8px;\n font-weight:900;\n text-transform:uppercase;\n}\n\n.tp18-gift-icons{\n display:flex;\n flex-wrap:wrap;\n gap:8px;\n}\n\n.tp18-gift-owned{\n display:flex;\n align-items:center;\n gap:5px;\n min-height:35px;\n padding:4px 8px 4px 4px;\n border:1px solid #e9d5ff;\n border-radius:999px;\n background:#fff;\n}\n\n.tp18-gift-owned img,\n.tp18-gift-icon-fallback{\n width:29px;\n height:29px;\n flex:0 0 29px;\n}\n\n.tp18-gift-owned img{\n object-fit:contain;\n}\n\n.tp18-gift-icon-fallback{\n display:flex;\n align-items:center;\n justify-content:center;\n border-radius:50%;\n background:#f3e8ff;\n font-size:17px;\n}\n\n.tp18-gift-count{\n color:#6b21a8;\n font-size:10px;\n font-weight:900;\n}\n\n/* =========================================================\n ADMIN GIFT - COLLAPSIBLE\n========================================================= */\n\n.tp18-admin-gift{\n margin-bottom:10px;\n border:1px solid #ddd6fe;\n border-radius:10px;\n background:#faf9ff;\n overflow:hidden;\n}\n\n.tp18-admin-gift-toggle{\n width:100%;\n min-height:38px;\n padding:8px 11px;\n display:flex;\n align-items:center;\n justify-content:space-between;\n gap:8px;\n border:0;\n background:#f5f3ff;\n color:#5b21b6;\n cursor:pointer;\n font-size:10px;\n font-weight:900;\n text-align:left;\n}\n\n.tp18-admin-gift-toggle:hover{\n background:#ede9fe;\n}\n\n.tp18-admin-gift-toggle-text{\n min-width:0;\n display:flex;\n align-items:center;\n gap:6px;\n}\n\n.tp18-admin-gift-arrow{\n flex:0 0 auto;\n color:#7c3aed;\n font-size:13px;\n line-height:1;\n transition:transform .18s ease;\n}\n\n.tp18-admin-gift.open .tp18-admin-gift-arrow{\n transform:rotate(180deg);\n}\n\n.tp18-admin-gift-body{\n display:none;\n padding:9px;\n border-top:1px solid #e9d5ff;\n}\n\n.tp18-admin-gift.open .tp18-admin-gift-body{\n display:block;\n}\n\n.tp18-admin-gift select,\n.tp18-admin-gift input{\n width:100%;\n height:36px;\n padding:0 9px;\n box-sizing:border-box;\n border:1px solid #c4b5fd;\n border-radius:7px;\n background:#fff;\n color:#172033;\n font-size:10px;\n}\n\n.tp18-admin-gift input{\n margin-top:6px;\n}\n\n.tp18-gift-meta{\n min-height:0;\n padding:5px 2px 0;\n color:#64748b;\n font-size:8px;\n line-height:1.4;\n}\n\n.tp18-give{\n width:100%;\n margin-top:6px;\n padding:8px 10px;\n border:0;\n border-radius:7px;\n background:#7c3aed;\n color:#fff;\n cursor:pointer;\n font-size:9px;\n font-weight:900;\n}\n\n.tp18-give:disabled{\n opacity:.5;\n cursor:wait;\n}\n\n.tp18-give-result{\n display:none;\n margin-top:6px;\n padding:7px 8px;\n border-radius:7px;\n font-size:8px;\n line-height:1.45;\n font-weight:800;\n}\n\n.tp18-give-result.ok{\n display:block;\n background:#ecfdf5;\n color:#047857;\n}\n\n.tp18-give-result.wait{\n display:block;\n background:#eff6ff;\n color:#1d4ed8;\n}\n\n.tp18-give-result.error{\n display:block;\n background:#fef2f2;\n color:#b91c1c;\n}\n\n/* =========================================================\n FOOTER\n========================================================= */\n\n.tp18-footer{\n display:flex;\n align-items:center;\n justify-content:space-between;\n gap:8px;\n}\n\n.tp18-grade{\n display:inline-flex;\n align-items:center;\n justify-content:center;\n padding:10px 14px;\n border:0;\n border-radius:8px;\n background:#7c3aed;\n color:#fff;\n cursor:pointer;\n font-size:10px;\n font-weight:900;\n}\n\n.tp18-grade:disabled{\n opacity:.55;\n cursor:wait;\n}\n\n.tp18-time{\n color:#a1a7b3;\n font-size:8px;\n}\n\n/* =========================================================\n LOADING\n========================================================= */\n\n.tp18-loading,\n.tp18-empty{\n padding:40px 15px;\n text-align:center;\n color:#7c8597;\n}\n\n.tp18-empty{\n display:none;\n}\n\n.tp18-error{\n display:none;\n margin:18px 0;\n padding:17px;\n border-radius:11px;\n background:#fff4f4;\n color:#a51c30;\n text-align:center;\n}\n\n/* =========================================================\n GATE\n========================================================= */\n\n.tp18-gate{\n position:fixed;\n z-index:999999;\n inset:0;\n display:flex;\n align-items:center;\n justify-content:center;\n padding:20px;\n background:rgba(15,23,42,.96);\n font-family:Arial,sans-serif;\n}\n\n.tp18-gate-box{\n width:100%;\n max-width:380px;\n padding:27px 23px;\n border-radius:20px;\n background:#fff;\n text-align:center;\n}\n\n.tp18-gate-icon{\n width:62px;\n height:62px;\n margin:0 auto 14px;\n display:flex;\n align-items:center;\n justify-content:center;\n border-radius:18px;\n background:#7c3aed;\n color:#fff;\n font-size:28px;\n}\n\n.tp18-gate h3{\n margin:0 0 7px;\n}\n\n.tp18-gate p{\n color:#78839a;\n font-size:12px;\n}\n\n.tp18-gate input{\n width:100%;\n padding:12px;\n box-sizing:border-box;\n border:1px solid #d8dce6;\n border-radius:9px;\n text-align:center;\n font-size:17px;\n font-weight:900;\n letter-spacing:3px;\n}\n\n.tp18-gate button{\n width:100%;\n margin-top:10px;\n padding:12px;\n border:0;\n border-radius:9px;\n background:#7c3aed;\n color:#fff;\n font-weight:900;\n cursor:pointer;\n}\n\n.tp18-gate-error{\n min-height:17px;\n margin-top:8px;\n color:#dc2626;\n font-size:10px;\n}\n\n/* =========================================================\n ANIMATION\n========================================================= */\n\n@keyframes tp18Float{\n 0%,100%{transform:translateY(1px);}\n 50%{transform:translateY(-4px);}\n}\n\n@keyframes tp18Pulse{\n 0%,100%{opacity:.35;transform:scale(.8);}\n 50%{opacity:1;transform:scale(1.2);}\n}\n\n@keyframes tp18DeletePulse{\n 0%,100%{box-shadow:0 5px 15px rgba(15,23,42,.3);}\n 50%{\n box-shadow:\n 0 0 0 6px rgba(245,158,11,.2),\n 0 5px 15px rgba(15,23,42,.3);\n }\n}\n\n/* =========================================================\n RESPONSIVE\n========================================================= */\n\n@media(max-width:900px){\n .tp18-grid{\n grid-template-columns:repeat(2,minmax(0,1fr));\n }\n}\n\n@media(max-width:600px){\n\n .tp18-wrap{\n padding:16px 10px 35px;\n }\n\n .tp18-grid{\n grid-template-columns:1fr;\n gap:18px;\n }\n\n .tp18-image-box{\n height:330px;\n }\n\n .tp18-student-bar.on{\n flex-direction:column;\n align-items:stretch;\n }\n\n .tp18-toast{\n top:12px;\n }\n\n .tp18-search-box{\n padding:7px;\n }\n\n .tp18-search-icon{\n width:38px;\n height:38px;\n flex-basis:38px;\n font-size:18px;\n }\n\n .tp18-search-input{\n height:38px;\n font-size:13px;\n }\n\n .tp18-search-clear{\n padding:0 9px;\n min-height:34px;\n }\n\n .tp18-delete{\n width:44px;\n height:44px;\n top:9px;\n right:9px;\n font-size:24px;\n }\n}\n";document.head.appendChild(s)})();
window.__TP18_CORE_SOURCE="\n(()=>{\n\"use strict\";\n\n/* =========================================================\n CONFIG\n========================================================= */\n\nconst ADMIN_MODE = true;\n\nconst ADMIN_ACCESS_CODE = \"835847\";\n\nconst ADMIN_GIVER = \"GVCN\";\n\n/*\n PHÂN TRANG MỚI:\n - MOBILE <= 600px : 10 bài / trang\n - WEB > 600px : 24 bài / trang\n*/\nconst WEB_PAGE_SIZE = 24;\nconst MOBILE_PAGE_SIZE = 10;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nconst PRIORITY_DAYS = 7;\n\nconst REFRESH_MS =\n5 * 60 * 1000;\n\n\n/* =========================================================\n V18.4 - RELIABLE FAST GRADING\n========================================================= */\n\nconst GRADE_SEND_GAP_MS = 700;\n\nconst GRADE_SYNC_FIRST_CHECK = 8000;\n\nconst GRADE_SYNC_INTERVAL = 15000;\n\nconst GRADE_SYNC_WARNING_AFTER =\n2 * 60 * 1000;\n\nconst GRADE_SYNC_ACTIVE_WINDOW =\n5 * 60 * 1000;\n\nconst GRADE_IFRAME_LIFETIME =\n30000;\n\n\n/* =========================================================\n DELETE\n========================================================= */\n\nconst DELETE_VERIFY_FIRST_DELAY =\n2500;\n\nconst DELETE_VERIFY_INTERVAL =\n2500;\n\nconst DELETE_VERIFY_TRIES =\n12;\n\nconst DELETE_IFRAME_LIFETIME =\n30000;\n\n\n/* =========================================================\n GIFT VERIFY\n========================================================= */\n\nconst GIFT_VERIFY_TRIES = 7;\n\nconst GIFT_VERIFY_INTERVAL = 1400;\n\n\n/* =========================================================\n SAFE CHARACTERS\n========================================================= */\n\nconst CHAR = {\n\n DOT:String.fromCodePoint(0x00B7),\n\n CHECK:String.fromCodePoint(0x2713),\n\n CROSS:String.fromCodePoint(0x2715),\n\n DASH:String.fromCodePoint(0x2013),\n\n EM_DASH:String.fromCodePoint(0x2014),\n\n LEFT:String.fromCodePoint(0x2039),\n\n RIGHT:String.fromCodePoint(0x203A),\n\n ELLIPSIS:String.fromCodePoint(0x2026),\n\n MULTIPLY:String.fromCodePoint(0x00D7)\n};\n\n\nconst UI = {\n\n TOOL:String.fromCodePoint(0x1F6E0),\n\n WARNING:String.fromCodePoint(0x26A0),\n\n PIN:String.fromCodePoint(0x1F4CC),\n\n PEOPLE:String.fromCodePoint(0x1F465),\n\n GRADUATION:String.fromCodePoint(0x1F393),\n\n GIFT:String.fromCodePoint(0x1F381),\n\n BOOKS:String.fromCodePoint(0x1F4DA),\n\n LOCK:String.fromCodePoint(0x1F510),\n\n IMAGE:String.fromCodePoint(0x1F5BC),\n\n POPCORN:String.fromCodePoint(0x1F37F),\n\n RULER:String.fromCodePoint(0x1F4CF),\n\n BRUSH:String.fromCodePoint(0x1F58C),\n\n SCROLL:String.fromCodePoint(0x1F4DC),\n\n PAW:String.fromCodePoint(0x1F43E),\n\n BACKPACK:String.fromCodePoint(0x1F392),\n\n FRAME:String.fromCodePoint(0x1F5BC),\n\n FOX:String.fromCodePoint(0x1F98A),\n\n FIRE:String.fromCodePoint(0x1F525),\n\n DEER:String.fromCodePoint(0x1F98C),\n\n UNICORN:String.fromCodePoint(0x1F984),\n\n CAT:String.fromCodePoint(0x1F431),\n\n OWL:String.fromCodePoint(0x1F989),\n\n TURTLE:String.fromCodePoint(0x1F422),\n\n EAGLE:String.fromCodePoint(0x1F985),\n\n DRAGON:String.fromCodePoint(0x1F409),\n\n SEARCH:String.fromCodePoint(0x1F50E),\n\n SYNC:String.fromCodePoint(0x1F504),\n\n TRASH:String.fromCodePoint(0x1F5D1)\n};\n\n\n/* =========================================================\n GOOGLE SHEETS\n========================================================= */\n\nconst NOP_BAI_ID =\n\"1GJoTRsbq0kZfZrDdh0uCC667PwS3Bgkje2fHQnwnCKs\";\n\nconst DOI_QUA_ID =\n\"1-IkcpEkKQtIavl5DIf6Sbwx3p0aAfnSS4HjT6dn1u_E\";\n\n\nconst URLS = {\n\n works:\n \"https://docs.google.com/spreadsheets/d/e/2PACX-1vRP5cc8duj1XrCXMrymo6Cj7aqIkWfX6bHxGeW-lXcSewfQXhM8fZ5rzbNIQ9mBeVuB8yYr_o1aBoYA/pub?gid=1023688821&single=true&output=csv\",\n\n worksFallback:\n \"https://docs.google.com/spreadsheets/d/\" +\n NOP_BAI_ID +\n \"/export?format=csv&gid=1837470623\",\n\n deleteLog:\n \"https://docs.google.com/spreadsheets/d/\" +\n NOP_BAI_ID +\n \"/export?format=csv&gid=521976322\",\n\n giftCatalog:\n \"https://docs.google.com/spreadsheets/d/\" +\n DOI_QUA_ID +\n \"/export?format=csv&gid=0\",\n\n exchanges:\n \"https://docs.google.com/spreadsheets/d/\" +\n DOI_QUA_ID +\n \"/export?format=csv&gid=267584332\",\n\n teacherGifts:\n \"https://docs.google.com/spreadsheets/d/\" +\n DOI_QUA_ID +\n \"/export?format=csv&gid=1993190794\"\n};\n\n\n/* =========================================================\n FORM CHẤM BÀI\n========================================================= */\n\nconst GRADE_FORM = {\n\n url:\n \"https://docs.google.com/forms/d/e/1FAIpQLSdd-zhAiZtu-Iv32PpJnaxLSDAwXkBno1-pkL-XPJkjiGttFw/formResponse\",\n\n entries:{\n\n submissionId:\"entry.651167670\",\n studentName:\"entry.1216519509\",\n studentCode:\"entry.1355978883\",\n workUrl:\"entry.1938510941\",\n score:\"entry.1045838415\",\n comment:\"entry.1918238409\"\n }\n};\n\n\n/* =========================================================\n FORM TẶNG QUÀ\n========================================================= */\n\nconst GIFT_FORM = {\n\n url:\n \"https://docs.google.com/forms/d/e/1FAIpQLSc48-Y-3nf4GtAKdE-Gq4uOMgOJSBUzvFruoSeQx8qmAok06Q/formResponse\",\n\n entries:{\n\n scope:\"entry.1737038806\",\n studentCode:\"entry.583300350\",\n group:\"entry.1930196666\",\n course:\"entry.1043199349\",\n rewardType:\"entry.1978772868\",\n giftName:\"entry.1254676473\",\n gemstoneType:\"entry.1706258741\",\n quantity:\"entry.606656855\",\n reason:\"entry.360182186\",\n giver:\"entry.1383532227\",\n status:\"entry.1295235585\"\n }\n};\n\n\n/* =========================================================\n FORM XOÁ BÀI\n========================================================= */\n\nconst DELETE_FORM = {\n\n url:\n \"https://docs.google.com/forms/d/e/1FAIpQLSe5IwwkOXjb3HSjK7n93ADsSOuE3S1IRCQtCQ0-0mCQRrv5NQ/formResponse\",\n\n entries:{\n\n submissionId:\"entry.441258267\",\n studentCode:\"entry.2026726963\",\n studentName:\"entry.1937657414\",\n workUrl:\"entry.1046985373\",\n submittedAt:\"entry.1369592128\",\n actor:\"entry.1199549067\",\n reason:\"entry.1666030213\",\n action:\"entry.785772148\"\n }\n};\n\n\n/* =========================================================\n LINH THÚ\n========================================================= */\n\nconst BEAST_KEYS = {\n\n WIND_DRAGON:\n \"linh thu thanh phong long\",\n\n TURTLE:\n \"linh thu huyen giap quy\"\n};\n\n\nconst GIFT_SUBMISSION_MARKER =\n\"BAI_ID\";\n\n\n/* =========================================================\n STATE\n========================================================= */\n\nlet works = [];\nlet adminSortedWorks = [];\nlet giftCatalog = [];\nlet exchangeData = [];\nlet teacherGiftData = [];\nlet deleteLogData = [];\nlet beastCatalog = [];\n\nlet ownershipMap = new Map();\nlet giftIconMap = new Map();\nlet latestSubmissionMap = new Map();\nlet teacherGiftStudentIndex = new Map();\nlet teacherGiftWorkCache = new Map();\n\nlet giftOptionsCache = \"\";\n\nlet deletedSubmissionIds = new Set();\n\nlet currentSearchResults = [];\nlet currentStudentResults = [];\n\nlet currentPage = 1;\n\nlet currentView = \"latest\";\n\nlet selectedStudentName = \"\";\nlet adminSearchQuery = \"\";\nlet sourceName = \"\";\n\nlet started = false;\nlet toastTimer = null;\nlet searchTimer = null;\nlet priorityCountCache = 0;\n\n/* =========================================================\n V18.5 - REWARD CORE CANONICAL ASSETS\n Tài sản cuối cùng chỉ đọc từ StudentRewardSystem dùng chung.\n========================================================= */\n\nlet rewardCoreReadyPromise = null;\nconst canonicalAssetMap = new Map();\nconst canonicalAssetPromises = new Map();\n\nfunction getRewardCore(){\n return window.StudentRewardSystem || null;\n}\n\nfunction isRewardCoreReady(){\n const RS = getRewardCore();\n return Boolean(\n RS &&\n typeof RS.getStudentRewardProfile === \"function\" &&\n typeof RS.loadSharedRewardData === \"function\"\n );\n}\n\nfunction waitRewardCore(){\n if(isRewardCoreReady()){\n return Promise.resolve(getRewardCore());\n }\n\n if(rewardCoreReadyPromise){\n return rewardCoreReadyPromise;\n }\n\n rewardCoreReadyPromise = new Promise(function(resolve,reject){\n let tries = 0;\n const timer = setInterval(function(){\n tries++;\n\n if(isRewardCoreReady()){\n clearInterval(timer);\n resolve(getRewardCore());\n return;\n }\n\n if(tries >= 200){\n clearInterval(timer);\n rewardCoreReadyPromise = null;\n reject(new Error(\"StudentRewardSystem Core chưa sẵn sàng.\"));\n }\n },100);\n });\n\n return rewardCoreReadyPromise;\n}\n\nfunction normalizeCanonicalRewardProfile(RS,rawProfile,studentCode){\n const raw =\n rawProfile && typeof rawProfile === \"object\"\n ? rawProfile\n : {};\n\n const root =\n raw.profile && typeof raw.profile === \"object\"\n ? raw.profile\n : raw;\n\n let gems = {};\n\n if(root.gems && typeof root.gems === \"object\"){\n gems = root.gems;\n }else if(root.balance && typeof root.balance === \"object\"){\n gems = root.balance;\n }else if(\n root.rewardData &&\n root.rewardData.gems &&\n typeof root.rewardData.gems === \"object\"\n ){\n gems = root.rewardData.gems;\n }\n\n const ownedItems =\n Array.isArray(root.ownedItems)\n ? root.ownedItems\n : [];\n\n return{\n code:\n typeof RS.normalizeCode === \"function\"\n ? RS.normalizeCode(studentCode)\n : normalize(studentCode),\n\n profile:root,\n ownedItems:ownedItems,\n gems:gems\n };\n}\n\nfunction csvEscapeCell(value){\n const text = String(value ?? \"\");\n\n if(/[\",\\r\\n]/.test(text)){\n return '\"' + text.replace(/\"/g,'\"\"') + '\"';\n }\n\n return text;\n}\n\nfunction objectRowsToCSV(rows){\n if(!Array.isArray(rows) || !rows.length){\n return \"\";\n }\n\n const headerSet = new Set();\n\n rows.forEach(function(row){\n Object.keys(row || {}).forEach(function(key){\n if(key && !key.startsWith(\"__\")){\n headerSet.add(key);\n }\n });\n });\n\n const headers = [...headerSet];\n\n if(!headers.length){\n return \"\";\n }\n\n const lines = [\n headers.map(csvEscapeCell).join(\",\")\n ];\n\n rows.forEach(function(row){\n lines.push(\n headers\n .map(function(key){\n return csvEscapeCell(row[key] ?? \"\");\n })\n .join(\",\")\n );\n });\n\n return lines.join(\"\\r\\n\");\n}\n\nfunction getValidStudentWorksForCore(studentCode){\n const target = normalize(studentCode);\n\n if(!target){\n return [];\n }\n\n return works.filter(function(item){\n const submissionId = workSubmissionId(item);\n\n if(\n submissionId &&\n deletedSubmissionIds.has(submissionId)\n ){\n return false;\n }\n\n return normalize(\n item.__code ||\n column(item,[\"Mã học viên\"])\n ) === target;\n });\n}\n\nfunction buildStudentSubmissionCSVForCore(studentCode){\n return objectRowsToCSV(\n getValidStudentWorksForCore(studentCode)\n );\n}\n\nasync function loadCanonicalStudentAssets(studentCode,force){\n const code = clean(studentCode);\n\n if(!code){\n return null;\n }\n\n const key = normalize(code);\n\n if(!force && canonicalAssetMap.has(key)){\n return canonicalAssetMap.get(key);\n }\n\n if(!force && canonicalAssetPromises.has(key)){\n return canonicalAssetPromises.get(key);\n }\n\n const promise = (async function(){\n const RS = await waitRewardCore();\n\n await RS.loadSharedRewardData(Boolean(force));\n\n const rawProfile =\n await RS.getStudentRewardProfile(\n code,\n buildStudentSubmissionCSVForCore(code),\n Boolean(force),\n {\n adminBypass:true\n }\n );\n\n const canonical =\n normalizeCanonicalRewardProfile(\n RS,\n rawProfile,\n code\n );\n\n canonicalAssetMap.set(key,canonical);\n\n return canonical;\n })();\n\n canonicalAssetPromises.set(key,promise);\n\n try{\n return await promise;\n }finally{\n canonicalAssetPromises.delete(key);\n }\n}\n\nasync function loadCanonicalAssetsForWorks(force){\n const codes = [\n ...new Set(\n works\n .map(function(item){\n return clean(\n item.__code ||\n column(item,[\"Mã học viên\"])\n );\n })\n .filter(Boolean)\n )\n ];\n\n const BATCH_SIZE = 6;\n\n for(let start=0; start<codes.length; start+=BATCH_SIZE){\n const batch = codes.slice(start,start+BATCH_SIZE);\n\n await Promise.all(\n batch.map(function(code){\n return loadCanonicalStudentAssets(code,force)\n .catch(function(error){\n console.warn(\n \"Reward Core không tải được hồ sơ:\",\n code,\n error\n );\n return null;\n });\n })\n );\n }\n}\n\nfunction canonicalItemName(item){\n if(typeof item === \"string\"){\n return clean(item);\n }\n\n if(!item || typeof item !== \"object\"){\n return \"\";\n }\n\n return clean(\n item.name ||\n item.itemName ||\n item.giftName ||\n item.tenVatPham ||\n item.title ||\n item.id ||\n item.key ||\n \"\"\n );\n}\n\nfunction canonicalItemQuantity(item){\n if(typeof item === \"string\"){\n return 1;\n }\n\n if(!item || typeof item !== \"object\"){\n return 0;\n }\n\n const raw =\n item.quantity ??\n item.qty ??\n item.soLuong ??\n item.count ??\n 1;\n\n const value = Number(raw);\n\n return Number.isFinite(value)\n ? value\n : 1;\n}\n\nfunction getCanonicalAssetRecord(code){\n return canonicalAssetMap.get(normalize(code)) || null;\n}\n\n\n\n/* =========================================================\n DELETE STATE\n========================================================= */\n\nconst pendingDeletes = new Map();\n\nlet deleteIframeCounter = 0;\n\n\n/* =========================================================\n PENDING GRADES\n========================================================= */\n\nconst pendingGrades = new Map();\n\n\n/* =========================================================\n GRADE SEND QUEUE\n========================================================= */\n\nconst gradeSendQueue = [];\n\nlet gradeSenderBusy = false;\n\nlet gradeIframeCounter = 0;\n\n\n/* =========================================================\n CENTRAL SYNC\n========================================================= */\n\nlet gradeSyncTimer = null;\n\nlet gradeSyncRunning = false;\n\n\n/* =========================================================\n DOM\n========================================================= */\n\nconst $ =\nselector =>\ndocument.querySelector(selector);\n\n\n/* =========================================================\n RESPONSIVE PAGE SIZE\n========================================================= */\n\nfunction getResponsivePageSize(){\n\n return window.matchMedia(\n \"(max-width:600px)\"\n ).matches\n ?\n MOBILE_PAGE_SIZE\n :\n WEB_PAGE_SIZE;\n}\n\n\n/* =========================================================\n NORMALIZE\n========================================================= */\n\nfunction decodeNumericEntities(value){\n\n return String(\n value ?? \"\"\n )\n\n .replace(\n /&#x([0-9a-f]+);?/gi,\n function(match,hex){\n\n const code =\n parseInt(hex,16);\n\n if(!Number.isFinite(code)){\n return match;\n }\n\n try{\n return String.fromCodePoint(code);\n }catch(error){\n return match;\n }\n }\n )\n\n .replace(\n /&#(\\d+);?/g,\n function(match,decimal){\n\n const code =\n parseInt(decimal,10);\n\n if(!Number.isFinite(code)){\n return match;\n }\n\n try{\n return String.fromCodePoint(code);\n }catch(error){\n return match;\n }\n }\n )\n\n .replace(\n / /gi,\n \" \"\n );\n}\n\n\nfunction clean(value){\n\n return decodeNumericEntities(\n value\n ).trim();\n}\n\n\nfunction normalize(value){\n\n return clean(value)\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(\n /[\\u0300-\\u036f]/g,\n \"\"\n )\n .replace(\n /đ/g,\n \"d\"\n )\n .replace(\n /\\s+/g,\n \" \"\n );\n}\n\n\nfunction setText(target,value){\n\n if(!target){\n return;\n }\n\n target.textContent =\n decodeNumericEntities(\n value\n );\n}\n\n\nfunction escapeHTML(value){\n\n return String(\n value ?? \"\"\n )\n .replace(/&/g,\"&\")\n .replace(/</g,\"<\")\n .replace(/>/g,\">\")\n .replace(/\"/g,\""\")\n .replace(/'/g,\"'\");\n}\n\n\nfunction displayValue(value){\n\n return(\n clean(value)\n ||\n CHAR.EM_DASH\n );\n}\n\n\nfunction sleep(ms){\n\n return new Promise(\n resolve =>\n setTimeout(resolve,ms)\n );\n}\n\n\n/* =========================================================\n TOAST\n========================================================= */\n\nfunction showToast(\n message,\n type,\n duration\n){\n\n const box =\n $(\"#tp18-toast\");\n\n if(!box){\n return;\n }\n\n if(toastTimer){\n\n clearTimeout(\n toastTimer\n );\n\n toastTimer = null;\n }\n\n box.className =\n \"tp18-toast on \" +\n (\n type ||\n \"success\"\n );\n\n setText(\n box,\n message\n );\n\n if(duration === 0){\n return;\n }\n\n toastTimer =\n setTimeout(\n function(){\n\n box.classList.remove(\n \"on\"\n );\n\n },\n Number(\n duration ||\n 2600\n )\n );\n}\n\n\n/* =========================================================\n CSV\n========================================================= */\n\nfunction parseCSV(text){\n\n const rows = [];\n\n let row = [];\n let value = \"\";\n let quote = false;\n\n text =\n String(text || \"\")\n .replace(/^\\uFEFF/,\"\");\n\n for(\n let i=0;\n i<text.length;\n i++\n ){\n\n const char = text[i];\n const next = text[i+1];\n\n if(\n char === '\"' &&\n quote &&\n next === '\"'\n ){\n\n value += '\"';\n i++;\n continue;\n }\n\n if(char === '\"'){\n\n quote = !quote;\n continue;\n }\n\n if(\n char === \",\" &&\n !quote\n ){\n\n row.push(value);\n value = \"\";\n continue;\n }\n\n if(\n (\n char === \"\\n\" ||\n char === \"\\r\"\n )\n &&\n !quote\n ){\n\n if(\n char === \"\\r\" &&\n next === \"\\n\"\n ){\n i++;\n }\n\n row.push(value);\n value = \"\";\n\n if(\n row.some(\n cell =>\n clean(cell)\n )\n ){\n rows.push(row);\n }\n\n row = [];\n\n continue;\n }\n\n value += char;\n }\n\n if(\n value ||\n row.length\n ){\n\n row.push(value);\n\n if(\n row.some(\n cell =>\n clean(cell)\n )\n ){\n rows.push(row);\n }\n }\n\n return rows;\n}\n\n\nfunction csvObjects(text){\n\n const rows =\n parseCSV(text);\n\n if(\n rows.length <\n 2\n ){\n return [];\n }\n\n const headers =\n rows[0]\n .map(normalize);\n\n return rows\n .slice(1)\n .filter(\n row =>\n row.some(\n cell =>\n clean(cell)\n )\n )\n .map(\n function(row){\n\n const object = {};\n\n headers.forEach(\n function(header,index){\n\n if(header){\n\n object[header] =\n row[index] ?? \"\";\n }\n }\n );\n\n return object;\n }\n );\n}\n\n\nfunction column(\n object,\n possibleNames\n){\n\n if(!object){\n return \"\";\n }\n\n for(\n const name\n of possibleNames\n ){\n\n const key =\n normalize(name);\n\n if(\n Object.prototype\n .hasOwnProperty.call(\n object,\n key\n )\n ){\n return object[key];\n }\n }\n\n return \"\";\n}\n\n\n/* =========================================================\n FETCH\n========================================================= */\n\nasync function fetchCSV(url){\n\n const separator =\n url.includes(\"?\")\n ?\n \"&\"\n :\n \"?\";\n\n const response =\n await fetch(\n\n url +\n separator +\n \"_=\" +\n Date.now(),\n\n {\n cache:\"no-store\"\n }\n );\n\n if(!response.ok){\n\n throw new Error(\n \"HTTP \" +\n response.status\n );\n }\n\n return response.text();\n}\n\n\nasync function safeFetchCSV(url){\n\n try{\n\n return await fetchCSV(\n url\n );\n\n }catch(error){\n\n console.warn(\n \"Nguồn phụ không tải được:\",\n error\n );\n\n return \"\";\n }\n}\n\n\n/* =========================================================\n WORK SOURCE\n========================================================= */\n\nfunction isValidWorkData(data){\n\n if(\n !Array.isArray(data) ||\n !data.length\n ){\n return false;\n }\n\n const refError =\n data\n .slice(0,15)\n .some(\n row =>\n Object.values(row)\n .some(\n value =>\n String(value)\n .includes(\"#REF!\")\n )\n );\n\n if(refError){\n return false;\n }\n\n return data.some(\n function(item){\n\n return Boolean(\n\n clean(\n column(\n item,\n [\"Mã học viên\"]\n )\n )\n\n &&\n\n clean(\n column(\n item,\n [\"Tải bài tập lên\"]\n )\n )\n );\n }\n );\n}\n\n\nasync function loadWorkData(){\n\n try{\n\n const primary =\n csvObjects(\n await fetchCSV(\n URLS.works\n )\n );\n\n if(\n isValidWorkData(\n primary\n )\n ){\n\n return{\n data:primary,\n source:\"TacPhamWeb\"\n };\n }\n\n }catch(error){\n\n console.warn(\n \"TacPhamWeb lỗi:\",\n error\n );\n }\n\n const fallback =\n csvObjects(\n await fetchCSV(\n URLS.worksFallback\n )\n );\n\n if(\n isValidWorkData(\n fallback\n )\n ){\n\n return{\n data:fallback,\n source:\"Form Responses 1\"\n };\n }\n\n throw new Error(\n \"Không tìm thấy nguồn dữ liệu bài tập hợp lệ.\"\n );\n}\n\n\n/* =========================================================\n DRIVE IMAGE\n========================================================= */\n\nfunction driveId(url){\n\n const value =\n String(url || \"\");\n\n const patterns = [\n\n /\\/file\\/d\\/([^/?&#]+)/i,\n\n /\\/d\\/([^/?&#]+)/i,\n\n /[?&]id=([^&#]+)/i\n ];\n\n for(\n const pattern\n of patterns\n ){\n\n const match =\n value.match(pattern);\n\n if(match){\n return match[1];\n }\n }\n\n return \"\";\n}\n\n\nfunction imageCandidates(url){\n\n const source =\n clean(url);\n\n const id =\n driveId(source);\n\n if(!id){\n\n return source\n ?\n [source]\n :\n [];\n }\n\n return[\n\n \"https://drive.google.com/thumbnail?id=\" +\n encodeURIComponent(id) +\n \"&sz=w600\",\n\n \"https://drive.google.com/uc?export=view&id=\" +\n encodeURIComponent(id),\n\n \"https://lh3.googleusercontent.com/d/\" +\n encodeURIComponent(id) +\n \"=w600\"\n ];\n}\n\n\nfunction primaryImage(url){\n\n return(\n imageCandidates(url)[0]\n ||\n \"\"\n );\n}\n\n\nfunction applyImageFallback(\n image,\n source,\n onBroken\n){\n\n const candidates =\n imageCandidates(source);\n\n let index = 0;\n\n image.addEventListener(\n \"error\",\n function(){\n\n index++;\n\n if(\n index <\n candidates.length\n ){\n\n image.src =\n candidates[index];\n\n return;\n }\n\n if(\n typeof onBroken ===\n \"function\"\n ){\n\n onBroken(image);\n }\n }\n );\n}\n\n\nfunction originalUrl(value){\n\n return(\n String(value || \"\")\n .match(\n /https?:\\/\\/[^\\s,]+/i\n )\n ||\n []\n )[0]\n ||\n \"#\";\n}\n\n\n/* =========================================================\n TIME\n========================================================= */\n\nfunction timestampValue(value){\n\n const match =\n String(value || \"\")\n .match(\n /^(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{4})(?:[\\s,]+(\\d{1,2}):(\\d{2})(?::(\\d{2}))?)?/\n );\n\n if(!match){\n\n return(\n Date.parse(value)\n ||\n 0\n );\n }\n\n return new Date(\n\n +match[3],\n\n +match[2]-1,\n\n +match[1],\n\n +match[4] || 0,\n\n +match[5] || 0,\n\n +match[6] || 0\n\n ).getTime();\n}\n\n\nfunction compactTimestamp(value){\n\n const match =\n String(value || \"\")\n .match(\n /^(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{4})[\\s,]+(\\d{1,2}):(\\d{2})(?::(\\d{2}))?/\n );\n\n if(!match){\n return \"\";\n }\n\n const pad =\n number =>\n String(number)\n .padStart(2,\"0\");\n\n return(\n match[3]\n +\n pad(match[2])\n +\n pad(match[1])\n +\n pad(match[4])\n +\n pad(match[5])\n +\n pad(match[6] || 0)\n );\n}\n\n\n/* =========================================================\n GOOGLE FORM DATE\n========================================================= */\n\nfunction toGoogleFormDate(value){\n\n const text =\n clean(value);\n\n if(!text){\n return \"\";\n }\n\n let match =\n text.match(\n /^(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{4})/\n );\n\n if(match){\n\n const day =\n String(match[1])\n .padStart(2,\"0\");\n\n const month =\n String(match[2])\n .padStart(2,\"0\");\n\n const year =\n match[3];\n\n return(\n year +\n \"-\" +\n month +\n \"-\" +\n day\n );\n }\n\n match =\n text.match(\n /^(\\d{4})-(\\d{1,2})-(\\d{1,2})/\n );\n\n if(match){\n\n return(\n match[1] +\n \"-\" +\n String(match[2]).padStart(2,\"0\") +\n \"-\" +\n String(match[3]).padStart(2,\"0\")\n );\n }\n\n return \"\";\n}\n\n\n/* =========================================================\n SUBMISSION ID\n========================================================= */\n\nfunction rawSubmissionId(item){\n\n const existing =\n clean(\n column(\n item,\n [\"Mã bài nộp\"]\n )\n );\n\n if(existing){\n return existing;\n }\n\n const timestamp =\n column(\n item,\n [\"Dấu thời gian\"]\n );\n\n const code =\n clean(\n column(\n item,\n [\"Mã học viên\"]\n )\n );\n\n const work =\n column(\n item,\n [\"Tải bài tập lên\"]\n );\n\n return(\n compactTimestamp(timestamp)\n +\n \"|\"\n +\n code\n +\n \"|\"\n +\n (\n driveId(work)\n ||\n clean(work)\n )\n );\n}\n\n\n/* =========================================================\n FIND SERVER WORK\n========================================================= */\n\nfunction findWorkBySubmissionId(\n data,\n submissionId\n){\n\n return(\n data.find(\n item =>\n rawSubmissionId(item)\n ===\n submissionId\n )\n ||\n null\n );\n}\n\n\n/* =========================================================\n GRADE MATCH\n========================================================= */\n\nfunction workGradeMatches(\n item,\n expected\n){\n\n if(!item){\n return false;\n }\n\n const score =\n clean(\n column(\n item,\n [\n \"Điểm số\",\n \"Điểm\",\n \"Điểm giáo viên\"\n ]\n )\n )\n .replace(\n \",\",\n \".\"\n );\n\n const comment =\n clean(\n column(\n item,\n [\n \"Nhận xét\",\n \"Nhận xét GVCN\"\n ]\n )\n );\n\n return(\n\n score ===\n clean(expected.score)\n .replace(\",\", \".\")\n\n &&\n\n comment ===\n clean(expected.comment)\n );\n}\n\n\n/* =========================================================\n RECONCILE PENDING GRADES\n========================================================= */\n\nfunction reconcilePendingGradesWithRawData(\n rawData,\n showSyncedState\n){\n\n if(\n !pendingGrades.size ||\n !Array.isArray(rawData) ||\n !rawData.length\n ){\n return 0;\n }\n\n let syncedCount = 0;\n\n const rawMap =\n new Map();\n\n rawData.forEach(\n function(item){\n\n const id =\n rawSubmissionId(item);\n\n if(id){\n\n rawMap.set(\n id,\n item\n );\n }\n }\n );\n\n pendingGrades.forEach(\n function(\n pending,\n submissionId\n ){\n\n if(\n pending.state ===\n \"queued\" ||\n pending.state ===\n \"sending\"\n ){\n return;\n }\n\n if(\n pending.state ===\n \"send-error\"\n ){\n return;\n }\n\n const rawWork =\n rawMap.get(\n submissionId\n );\n\n if(!rawWork){\n return;\n }\n\n if(\n workGradeMatches(\n rawWork,\n pending\n )\n ){\n\n pendingGrades.delete(\n submissionId\n );\n\n syncedCount++;\n\n if(showSyncedState){\n\n setCardSyncState(\n submissionId,\n \"synced\"\n );\n }\n }\n }\n );\n\n return syncedCount;\n}\n\n\n/* =========================================================\n OPTIMISTIC GRADE OVERLAY\n========================================================= */\n\nfunction applyPendingGradesToRawWorks(\n data\n){\n\n if(\n !pendingGrades.size\n ){\n return data;\n }\n\n data.forEach(\n function(item){\n\n const id =\n rawSubmissionId(\n item\n );\n\n const pending =\n pendingGrades.get(\n id\n );\n\n if(!pending){\n return;\n }\n\n if(\n pending.state ===\n \"send-error\"\n ){\n return;\n }\n\n item[\n normalize(\"Điểm số\")\n ] =\n pending.score;\n\n item[\n normalize(\"Nhận xét\")\n ] =\n pending.comment;\n }\n );\n\n return data;\n}\n\n\n/* =========================================================\n DELETE LOG\n========================================================= */\n\nfunction buildDeletedSubmissionIds(\n data\n){\n\n const states =\n new Map();\n\n (data || [])\n .forEach(\n function(row){\n\n const id =\n clean(\n column(\n row,\n [\"Mã bài nộp\"]\n )\n );\n\n if(!id){\n return;\n }\n\n const action =\n normalize(\n column(\n row,\n [\"Hành động\"]\n )\n );\n\n if(\n action === \"xoa\"\n ||\n action.includes(\"xoa\")\n ||\n action === \"delete\"\n ){\n\n states.set(\n id,\n true\n );\n\n return;\n }\n\n if(\n action.includes(\n \"khoi phuc\"\n )\n ||\n action ===\n \"restore\"\n ){\n\n states.set(\n id,\n false\n );\n }\n }\n );\n\n const next =\n new Set();\n\n states.forEach(\n function(\n isDeleted,\n id\n ){\n\n if(isDeleted){\n\n next.add(\n id\n );\n }\n }\n );\n\n deletedSubmissionIds =\n next;\n}\n\n\nfunction filterDeletedWorks(\n data\n){\n\n if(\n !deletedSubmissionIds.size\n ){\n return data;\n }\n\n return data.filter(\n function(item){\n\n return(\n !deletedSubmissionIds\n .has(\n rawSubmissionId(item)\n )\n );\n }\n );\n}\n\n\nfunction deleteLogHasSubmission(\n data,\n submissionId\n){\n\n let deleted =\n null;\n\n (data || [])\n .forEach(\n function(row){\n\n const id =\n clean(\n column(\n row,\n [\"Mã bài nộp\"]\n )\n );\n\n if(\n id !==\n submissionId\n ){\n return;\n }\n\n const action =\n normalize(\n column(\n row,\n [\"Hành động\"]\n )\n );\n\n if(\n action.includes(\n \"khoi phuc\"\n )\n ||\n action ===\n \"restore\"\n ){\n\n deleted =\n false;\n\n return;\n }\n\n if(\n action ===\n \"xoa\"\n ||\n action.includes(\n \"xoa\"\n )\n ||\n action ===\n \"delete\"\n ){\n\n deleted =\n true;\n }\n }\n );\n\n return deleted === true;\n}\n\n\n/* =========================================================\n WORK CACHE\n========================================================= */\n\nfunction prepareBaseWorkCache(){\n\n works.forEach(\n function(item){\n\n item.__name =\n clean(\n column(\n item,\n [\n \"Họ và tên\",\n \"Họ tên\"\n ]\n )\n );\n\n item.__nameNorm =\n normalize(\n item.__name\n );\n\n item.__code =\n clean(\n column(\n item,\n [\"Mã học viên\"]\n )\n );\n\n item.__codeNorm =\n normalize(\n item.__code\n );\n\n item.__group =\n clean(\n column(\n item,\n [\"Tổ\"]\n )\n );\n\n item.__course =\n clean(\n column(\n item,\n [\n \"Khóa\",\n \"Khoa\"\n ]\n )\n );\n\n item.__timestampText =\n clean(\n column(\n item,\n [\"Dấu thời gian\"]\n )\n );\n\n item.__timestamp =\n timestampValue(\n item.__timestampText\n );\n\n item.__score =\n clean(\n column(\n item,\n [\n \"Điểm số\",\n \"Điểm\",\n \"Điểm giáo viên\"\n ]\n )\n );\n\n item.__comment =\n clean(\n column(\n item,\n [\n \"Nhận xét\",\n \"Nhận xét GVCN\"\n ]\n )\n );\n\n item.__rawWork =\n column(\n item,\n [\"Tải bài tập lên\"]\n );\n\n item.__workUrl =\n originalUrl(\n item.__rawWork\n );\n\n item.__image =\n primaryImage(\n item.__rawWork\n );\n\n item.__submissionId =\n rawSubmissionId(item);\n\n if(\n item.__codeNorm\n ){\n\n item.__studentKey =\n \"code:\" +\n item.__codeNorm;\n\n }else if(\n item.__nameNorm\n ){\n\n item.__studentKey =\n \"name:\" +\n item.__nameNorm;\n\n }else{\n\n item.__studentKey =\n \"\";\n }\n }\n );\n}\n\n\nfunction workSubmissionId(item){\n\n if(\n item &&\n item.__submissionId\n ){\n return item.__submissionId;\n }\n\n return rawSubmissionId(\n item\n );\n}\n\n\nfunction studentKeyFromWork(item){\n\n if(\n item &&\n item.__studentKey !==\n undefined\n ){\n return item.__studentKey;\n }\n\n const code =\n normalize(\n column(\n item,\n [\"Mã học viên\"]\n )\n );\n\n if(code){\n return(\n \"code:\" +\n code\n );\n }\n\n const name =\n normalize(\n column(\n item,\n [\n \"Họ và tên\",\n \"Họ tên\"\n ]\n )\n );\n\n return name\n ?\n \"name:\" + name\n :\n \"\";\n}\n\n\n/* =========================================================\n LATEST SUBMISSION\n========================================================= */\n\nfunction buildLatestSubmissionMap(){\n\n latestSubmissionMap =\n new Map();\n\n works.forEach(\n function(item){\n\n const key =\n item.__studentKey;\n\n if(!key){\n return;\n }\n\n const current =\n latestSubmissionMap.get(\n key\n );\n\n if(\n !current ||\n item.__timestamp >\n current.time\n ){\n\n latestSubmissionMap.set(\n\n key,\n\n {\n time:\n item.__timestamp,\n\n id:\n item.__submissionId\n }\n );\n }\n }\n );\n}\n\n\nfunction isLatestSubmission(item){\n\n const latest =\n latestSubmissionMap.get(\n item.__studentKey\n ||\n studentKeyFromWork(item)\n );\n\n return Boolean(\n\n latest &&\n\n latest.id ===\n workSubmissionId(item)\n );\n}\n\n\nfunction withinLastDays(\n item,\n days\n){\n\n const submitted =\n item.__timestamp\n ||\n timestampValue(\n column(\n item,\n [\"Dấu thời gian\"]\n )\n );\n\n if(!submitted){\n return false;\n }\n\n const age =\n Date.now() -\n submitted;\n\n return(\n\n age >= 0\n\n &&\n\n age <=\n days * 86400000\n );\n}\n\n\n/* =========================================================\n CATALOG\n========================================================= */\n\nfunction rarityData(value){\n\n const key =\n normalize(value);\n\n if(key.includes(\"cuc pham\")){\n return{css:\"supreme\",rank:6};\n }\n\n if(key.includes(\"cao cap\")){\n return{css:\"premium\",rank:5};\n }\n\n if(key.includes(\"thuong pham\")){\n return{css:\"high\",rank:4};\n }\n\n if(key.includes(\"trung thuong\")){\n return{css:\"upper\",rank:3};\n }\n\n if(key.includes(\"trung pham\")){\n return{css:\"medium\",rank:2};\n }\n\n return{\n css:\"common\",\n rank:1\n };\n}\n\n\nfunction buildCatalogMaps(){\n\n giftIconMap =\n new Map();\n\n giftCatalog.forEach(\n function(item){\n\n const name =\n clean(\n column(\n item,\n [\"Tên quà\"]\n )\n );\n\n if(!name){\n return;\n }\n\n giftIconMap.set(\n\n normalize(name),\n\n {\n name:name,\n\n icon:\n clean(\n column(\n item,\n [\"Icon\"]\n )\n ),\n\n description:\n clean(\n column(\n item,\n [\"Mô tả\"]\n )\n ),\n\n rarity:\n clean(\n column(\n item,\n [\"Độ hiếm\"]\n )\n )\n }\n );\n }\n );\n\n beastCatalog =\n giftCatalog\n .map(\n function(item){\n\n const name =\n clean(\n column(\n item,\n [\"Tên quà\"]\n )\n );\n\n const rarity =\n rarityData(\n column(\n item,\n [\"Độ hiếm\"]\n )\n );\n\n return{\n\n name:name,\n\n key:\n normalize(name),\n\n icon:\n clean(\n column(\n item,\n [\"Icon\"]\n )\n ),\n\n rarity:\n clean(\n column(\n item,\n [\"Độ hiếm\"]\n )\n ),\n\n css:\n rarity.css,\n\n rank:\n rarity.rank\n };\n }\n )\n .filter(\n item =>\n item.key.includes(\n \"linh thu\"\n )\n );\n\n giftOptionsCache =\n buildGiftOptionsHTML();\n}\n\n\n/* =========================================================\n OWNERSHIP\n========================================================= */\n\nconst studentCodeKey =\nvalue =>\nnormalize(value)\n?\n\"code:\" +\nnormalize(value)\n:\n\"\";\n\n\nconst studentNameKey =\nvalue =>\nnormalize(value)\n?\n\"name:\" +\nnormalize(value)\n:\n\"\";\n\n\nfunction isActiveTeacherGift(row){\n\n const status =\n normalize(\n column(\n row,\n [\"Trạng thái\"]\n )\n );\n\n return(\n\n !status\n\n ||\n\n (\n !status.includes(\n \"het hieu luc\"\n )\n\n &&\n\n !status.includes(\n \"da huy\"\n )\n\n &&\n\n status !==\n \"huy\"\n )\n );\n}\n\n\nfunction addOwnedBeast(\n key,\n beast\n){\n\n if(!key){\n return;\n }\n\n if(\n !ownershipMap.has(\n key\n )\n ){\n\n ownershipMap.set(\n key,\n []\n );\n }\n\n const list =\n ownershipMap.get(key);\n\n if(\n !list.some(\n existing =>\n existing.key ===\n beast.key\n )\n ){\n\n list.push(beast);\n }\n}\n\n\nfunction scanOwnership(\n row,\n isTeacherGift\n){\n\n if(\n isTeacherGift &&\n !isActiveTeacherGift(row)\n ){\n return;\n }\n\n const code =\n column(\n row,\n [\"Mã học viên\"]\n );\n\n const name =\n column(\n row,\n [\n \"Họ tên\",\n \"Họ và tên\"\n ]\n );\n\n const giftTexts = [\n\n column(\n row,\n [\"Món quà muốn đổi\"]\n ),\n\n column(\n row,\n [\"Quà nhận được\"]\n ),\n\n column(\n row,\n [\"Tên vật phẩm\"]\n )\n ];\n\n giftTexts.forEach(\n function(text){\n\n const normalizedText =\n normalize(text);\n\n if(!normalizedText){\n return;\n }\n\n beastCatalog.forEach(\n function(beast){\n\n if(\n !normalizedText.includes(\n beast.key\n )\n ){\n return;\n }\n\n addOwnedBeast(\n studentCodeKey(code),\n beast\n );\n\n addOwnedBeast(\n studentNameKey(name),\n beast\n );\n }\n );\n }\n );\n}\n\n\n\nfunction buildOwnershipMap(){\n /*\n V18.5:\n Không replay PhieuDoi/QuaTangGVCN tại trang Admin.\n canonicalAssetMap đã được StudentRewardSystem tính.\n */\n}\n\nfunction getStudentBeasts(code,name){\n const canonical = getCanonicalAssetRecord(code);\n\n if(!canonical){\n return [];\n }\n\n const result = [];\n\n canonical.ownedItems.forEach(function(owned){\n if(canonicalItemQuantity(owned) <= 0){\n return;\n }\n\n const ownedName = canonicalItemName(owned);\n const ownedKey = normalize(ownedName);\n\n if(!ownedKey){\n return;\n }\n\n let beast = beastCatalog.find(function(item){\n return(\n ownedKey === item.key ||\n ownedKey.includes(item.key) ||\n item.key.includes(ownedKey)\n );\n });\n\n if(!beast){\n if(!ownedKey.includes(\"linh thu\")){\n return;\n }\n\n beast = {\n name:ownedName,\n key:ownedKey,\n icon:\"\",\n rarity:\"\",\n css:\"common\",\n rank:1\n };\n }\n\n if(\n !result.some(function(existing){\n return existing.key === beast.key;\n })\n ){\n result.push(beast);\n }\n });\n\n return result.sort(function(a,b){\n return b.rank - a.rank;\n });\n}\n\nfunction studentOwns(code,name,beastKey){\n const target = normalize(beastKey);\n\n if(!target){\n return false;\n }\n\n return getStudentBeasts(code,name)\n .some(function(beast){\n return(\n beast.key === target ||\n beast.key.includes(target) ||\n target.includes(beast.key)\n );\n });\n}\n\n\n/* =========================================================\n PRIORITY\n========================================================= */\n\nfunction calculatePriorityState(item){\n\n const empty = {\n\n pinned:false,\n\n needsScore:false,\n\n needsComment:false,\n\n label:\"\"\n };\n\n if(!ADMIN_MODE){\n return empty;\n }\n\n if(\n !isLatestSubmission(\n item\n )\n ){\n return empty;\n }\n\n if(\n !withinLastDays(\n item,\n PRIORITY_DAYS\n )\n ){\n return empty;\n }\n\n const windDragon =\n studentOwns(\n item.__code,\n item.__name,\n BEAST_KEYS.WIND_DRAGON\n );\n\n const turtle =\n studentOwns(\n item.__code,\n item.__name,\n BEAST_KEYS.TURTLE\n );\n\n const needsScore =\n Boolean(\n\n windDragon\n\n &&\n\n item.__score === \"\"\n );\n\n const needsComment =\n Boolean(\n\n turtle\n\n &&\n\n item.__comment === \"\"\n );\n\n const labels = [];\n\n if(needsScore){\n\n labels.push(\n \"Ưu tiên chấm điểm\"\n );\n }\n\n if(needsComment){\n\n labels.push(\n \"Ưu tiên nhận xét\"\n );\n }\n\n return{\n\n pinned:\n needsScore ||\n needsComment,\n\n needsScore:\n needsScore,\n\n needsComment:\n needsComment,\n\n label:\n labels.join(\n \" \" +\n CHAR.DOT +\n \" \"\n )\n };\n}\n\n\nfunction buildPriorityCache(){\n\n priorityCountCache =\n 0;\n\n works.forEach(\n function(item){\n\n item.__beasts =\n getStudentBeasts(\n item.__code,\n item.__name\n );\n\n item.__priority =\n calculatePriorityState(\n item\n );\n\n if(\n item.__priority.pinned\n ){\n priorityCountCache++;\n }\n }\n );\n}\n\n\n/* =========================================================\n SORT\n========================================================= */\n\nfunction newestSort(a,b){\n\n return(\n (b.__timestamp || 0)\n -\n (a.__timestamp || 0)\n );\n}\n\n\nfunction prioritySort(a,b){\n\n const aPriority =\n a.__priority &&\n a.__priority.pinned\n ?\n 1\n :\n 0;\n\n const bPriority =\n b.__priority &&\n b.__priority.pinned\n ?\n 1\n :\n 0;\n\n if(\n bPriority !==\n aPriority\n ){\n\n return(\n bPriority -\n aPriority\n );\n }\n\n return newestSort(\n a,\n b\n );\n}\n\n\nfunction buildSortedWorks(){\n\n adminSortedWorks =\n works\n .slice()\n .sort(\n prioritySort\n );\n}\n\n\n/* =========================================================\n SEARCH\n========================================================= */\n\nfunction buildSearchResults(){\n\n const query =\n normalize(\n adminSearchQuery\n );\n\n if(!query){\n\n currentSearchResults =\n [];\n\n return;\n }\n\n currentSearchResults =\n adminSortedWorks.filter(\n function(item){\n\n return(\n\n item.__nameNorm\n .includes(query)\n\n ||\n\n item.__codeNorm\n .includes(query)\n );\n }\n );\n}\n\n\nfunction countStudents(data){\n\n const students =\n new Set();\n\n data.forEach(\n function(item){\n\n if(\n item.__studentKey\n ){\n\n students.add(\n item.__studentKey\n );\n }\n }\n );\n\n return students.size;\n}\n\n\nfunction updateSearchSummary(){\n\n if(!ADMIN_MODE){\n return;\n }\n\n const info =\n $(\"#tp18-search-info\");\n\n const clearButton =\n $(\"#tp18-search-clear\");\n\n if(\n !info ||\n !clearButton\n ){\n return;\n }\n\n const query =\n clean(\n adminSearchQuery\n );\n\n clearButton.classList.toggle(\n \"on\",\n Boolean(query)\n );\n\n if(!query){\n\n info.classList.remove(\n \"active\"\n );\n\n setText(\n info,\n\n works.length +\n \" bài \" +\n CHAR.DOT +\n \" \" +\n countStudents(works) +\n \" học viên\"\n );\n\n return;\n }\n\n info.classList.add(\n \"active\"\n );\n\n setText(\n info,\n\n UI.SEARCH +\n ' \"' +\n query +\n '\" ' +\n CHAR.DOT +\n \" \" +\n countStudents(\n currentSearchResults\n ) +\n \" học viên \" +\n CHAR.DOT +\n \" \" +\n currentSearchResults.length +\n \" bài nộp\"\n );\n}\n\n\nfunction showSearchingState(){\n\n const info =\n $(\"#tp18-search-info\");\n\n if(!info){\n return;\n }\n\n info.classList.add(\n \"active\"\n );\n\n setText(\n info,\n \"Đang tìm...\"\n );\n}\n\n\nfunction clearAdminSearch(){\n\n adminSearchQuery =\n \"\";\n\n currentSearchResults =\n [];\n\n const input =\n $(\"#tp18-search-input\");\n\n if(input){\n input.value = \"\";\n }\n\n if(searchTimer){\n\n clearTimeout(\n searchTimer\n );\n\n searchTimer =\n null;\n }\n\n currentPage =\n 1;\n\n showLatest();\n\n updateSearchSummary();\n\n if(input){\n input.focus();\n }\n}\n\n\n/* =========================================================\n TEACHER GIFTS\n========================================================= */\n\nfunction buildGiftReason(\n reason,\n submissionId\n){\n\n return(\n (\n clean(reason)\n ||\n \"GVCN tặng từ trang chấm bài\"\n )\n +\n \" [\" +\n GIFT_SUBMISSION_MARKER +\n \":\" +\n submissionId +\n \"]\"\n );\n}\n\n\nfunction extractGiftSubmissionId(reason){\n\n const match =\n String(reason || \"\")\n .match(\n /\\[BAI_ID:([^\\]]+)\\]/i\n );\n\n return match\n ?\n clean(match[1])\n :\n \"\";\n}\n\n\nfunction buildTeacherGiftStudentIndex(){\n\n teacherGiftStudentIndex =\n new Map();\n\n teacherGiftWorkCache =\n new Map();\n\n teacherGiftData.forEach(\n function(row){\n\n const code =\n normalize(\n column(\n row,\n [\"Mã học viên\"]\n )\n );\n\n if(!code){\n return;\n }\n\n if(\n !teacherGiftStudentIndex\n .has(code)\n ){\n\n teacherGiftStudentIndex\n .set(\n code,\n []\n );\n }\n\n teacherGiftStudentIndex\n .get(code)\n .push(row);\n }\n );\n}\n\n\nfunction getTeacherGiftsForWork(item){\n\n const submissionId =\n item.__submissionId;\n\n if(\n teacherGiftWorkCache\n .has(submissionId)\n ){\n\n return teacherGiftWorkCache\n .get(submissionId);\n }\n\n const code =\n item.__codeNorm;\n\n const latest =\n isLatestSubmission(\n item\n );\n\n const rows =\n teacherGiftStudentIndex\n .get(code)\n ||\n [];\n\n const grouped =\n new Map();\n\n rows.forEach(\n function(row){\n\n if(\n !isActiveTeacherGift(\n row\n )\n ){\n return;\n }\n\n const type =\n normalize(\n column(\n row,\n [\"Loại phần thưởng\"]\n )\n );\n\n if(\n type &&\n !type.includes(\n \"vat pham\"\n )\n ){\n return;\n }\n\n const rowSubmission =\n extractGiftSubmissionId(\n column(\n row,\n [\"Lý do tặng\"]\n )\n );\n\n if(\n rowSubmission &&\n rowSubmission !==\n submissionId\n ){\n return;\n }\n\n if(\n !rowSubmission &&\n !latest\n ){\n return;\n }\n\n const giftName =\n clean(\n column(\n row,\n [\"Tên vật phẩm\"]\n )\n );\n\n if(!giftName){\n return;\n }\n\n const quantity =\n Math.max(\n 1,\n Number(\n column(\n row,\n [\"Số lượng\"]\n )\n )\n ||\n 1\n );\n\n const key =\n normalize(\n giftName\n );\n\n if(\n !grouped.has(\n key\n )\n ){\n\n grouped.set(\n\n key,\n\n {\n name:\n giftName,\n\n quantity:\n 0\n }\n );\n }\n\n grouped.get(\n key\n )\n .quantity +=\n quantity;\n }\n );\n\n const result =\n [...grouped.values()];\n\n teacherGiftWorkCache.set(\n submissionId,\n result\n );\n\n return result;\n}\n\n\n/* =========================================================\n GIFT ICON\n========================================================= */\n\nfunction giftEmoji(name){\n\n const key =\n normalize(name);\n\n if(key.includes(\"bim bim\")) return UI.POPCORN;\n if(key.includes(\"thuoc ke\")) return UI.RULER;\n if(key.includes(\"but\")) return UI.BRUSH;\n if(key.includes(\"giay\")) return UI.SCROLL;\n if(key.includes(\"linh thu\")) return UI.PAW;\n if(key.includes(\"tui\")) return UI.BACKPACK;\n if(key.includes(\"khung\")) return UI.FRAME;\n\n return UI.GIFT;\n}\n\n\nfunction createGiftOwnedNode(gift){\n\n const root =\n document.createElement(\n \"div\"\n );\n\n root.className =\n \"tp18-gift-owned\";\n\n const catalog =\n giftIconMap.get(\n normalize(gift.name)\n );\n\n if(\n catalog &&\n catalog.icon\n ){\n\n const image =\n document.createElement(\n \"img\"\n );\n\n image.src =\n primaryImage(\n catalog.icon\n );\n\n image.alt =\n gift.name;\n\n image.loading =\n \"lazy\";\n\n image.decoding =\n \"async\";\n\n root.appendChild(\n image\n );\n\n applyImageFallback(\n\n image,\n\n catalog.icon,\n\n function(broken){\n\n const fallback =\n document.createElement(\n \"div\"\n );\n\n fallback.className =\n \"tp18-gift-icon-fallback\";\n\n setText(\n fallback,\n giftEmoji(\n gift.name\n )\n );\n\n broken.replaceWith(\n fallback\n );\n }\n );\n\n }else{\n\n const fallback =\n document.createElement(\n \"div\"\n );\n\n fallback.className =\n \"tp18-gift-icon-fallback\";\n\n setText(\n fallback,\n giftEmoji(\n gift.name\n )\n );\n\n root.appendChild(\n fallback\n );\n }\n\n const count =\n document.createElement(\n \"span\"\n );\n\n count.className =\n \"tp18-gift-count\";\n\n setText(\n count,\n \"+ \" +\n gift.quantity\n );\n\n root.title =\n clean(gift.name)\n +\n \" \"\n +\n CHAR.MULTIPLY\n +\n \" \"\n +\n gift.quantity;\n\n root.appendChild(\n count\n );\n\n return root;\n}\n\n\n/* =========================================================\n BEAST\n========================================================= */\n\nfunction beastEmoji(name){\n\n const key =\n normalize(name);\n\n if(key.includes(\"cuu vi\")) return UI.FOX;\n if(key.includes(\"phuong\")) return UI.FIRE;\n if(key.includes(\"linh quang loc\")) return UI.DEER;\n if(key.includes(\"ky lan\")) return UI.UNICORN;\n if(key.includes(\"meo\")) return UI.CAT;\n if(key.includes(\"cu\")) return UI.OWL;\n if(key.includes(\"quy\")) return UI.TURTLE;\n if(key.includes(\"kim si\")) return UI.EAGLE;\n if(key.includes(\"rong\")) return UI.DRAGON;\n\n return UI.PAW;\n}\n\n\nfunction createBeastNode(beast){\n\n const root =\n document.createElement(\n \"div\"\n );\n\n root.className =\n \"tp18-beast \" +\n beast.css;\n\n if(beast.icon){\n\n const image =\n document.createElement(\n \"img\"\n );\n\n image.src =\n primaryImage(\n beast.icon\n );\n\n image.alt =\n beast.name;\n\n image.loading =\n \"lazy\";\n\n image.decoding =\n \"async\";\n\n root.appendChild(\n image\n );\n\n applyImageFallback(\n\n image,\n\n beast.icon,\n\n function(broken){\n\n const fallback =\n document.createElement(\n \"div\"\n );\n\n fallback.className =\n \"tp18-beast-fallback\";\n\n setText(\n fallback,\n beastEmoji(\n beast.name\n )\n );\n\n broken.replaceWith(\n fallback\n );\n }\n );\n\n }else{\n\n const fallback =\n document.createElement(\n \"div\"\n );\n\n fallback.className =\n \"tp18-beast-fallback\";\n\n setText(\n fallback,\n beastEmoji(\n beast.name\n )\n );\n\n root.appendChild(\n fallback\n );\n }\n\n const tooltip =\n document.createElement(\n \"div\"\n );\n\n tooltip.className =\n \"tp18-tooltip\";\n\n setText(\n tooltip,\n\n beast.name\n +\n (\n beast.rarity\n ?\n \" \" +\n CHAR.DOT +\n \" \" +\n beast.rarity\n :\n \"\"\n )\n );\n\n root.appendChild(\n tooltip\n );\n\n return root;\n}\n\n\n/* =========================================================\n GIFT OPTIONS\n========================================================= */\n\nfunction buildGiftOptionsHTML(){\n\n return[\n\n '<option value=\"\">-- Chọn vật phẩm GVCN tặng --</option>',\n\n ...giftCatalog\n .map(\n function(item){\n\n const name =\n clean(\n column(\n item,\n [\"Tên quà\"]\n )\n );\n\n if(!name){\n return \"\";\n }\n\n const rarity =\n displayValue(\n column(\n item,\n [\"Độ hiếm\"]\n )\n );\n\n return(\n '<option value=\"' +\n escapeHTML(name) +\n '\">' +\n escapeHTML(name) +\n \" \" +\n escapeHTML(CHAR.DOT) +\n \" \" +\n escapeHTML(rarity) +\n \"</option>\"\n );\n }\n )\n .filter(Boolean)\n\n ].join(\"\");\n}\n\n\nfunction findCatalogGift(name){\n\n const target =\n normalize(name);\n\n return giftCatalog.find(\n item =>\n normalize(\n column(\n item,\n [\"Tên quà\"]\n )\n )\n ===\n target\n );\n}\n\n\n/* =========================================================\n GIFT FORM POST\n========================================================= */\n\nfunction postHiddenForm(\n url,\n frameName,\n fields\n){\n\n const form =\n document.createElement(\n \"form\"\n );\n\n form.method =\n \"POST\";\n\n form.action =\n url;\n\n form.target =\n frameName;\n\n form.acceptCharset =\n \"UTF-8\";\n\n form.style.display =\n \"none\";\n\n Object.entries(\n fields\n )\n .forEach(\n function(entry){\n\n const input =\n document.createElement(\n \"input\"\n );\n\n input.type =\n \"hidden\";\n\n input.name =\n entry[0];\n\n input.value =\n String(\n entry[1] ?? \"\"\n );\n\n form.appendChild(\n input\n );\n }\n );\n\n document.body\n .appendChild(\n form\n );\n\n form.submit();\n\n setTimeout(\n () =>\n form.remove(),\n 2000\n );\n}\n\n\n/* =========================================================\n ISOLATED GRADE POST\n========================================================= */\n\nfunction submitGradeFormIsolated(data){\n\n const frameName =\n \"tp18-grade-frame-\" +\n Date.now() +\n \"-\" +\n (++gradeIframeCounter);\n\n const iframe =\n document.createElement(\n \"iframe\"\n );\n\n iframe.name =\n frameName;\n\n iframe.id =\n frameName;\n\n iframe.style.cssText =\n \"display:none!important;\" +\n \"width:0!important;\" +\n \"height:0!important;\" +\n \"border:0!important;\";\n\n document.body.appendChild(\n iframe\n );\n\n const form =\n document.createElement(\n \"form\"\n );\n\n form.method =\n \"POST\";\n\n form.action =\n GRADE_FORM.url;\n\n form.target =\n frameName;\n\n form.acceptCharset =\n \"UTF-8\";\n\n form.style.display =\n \"none\";\n\n const e =\n GRADE_FORM.entries;\n\n const fields = {};\n\n fields[e.submissionId] =\n data.submissionId;\n\n fields[e.studentName] =\n data.studentName;\n\n fields[e.studentCode] =\n data.studentCode;\n\n fields[e.workUrl] =\n data.workUrl;\n\n fields[e.score] =\n data.score;\n\n fields[e.comment] =\n data.comment;\n\n Object.entries(\n fields\n )\n .forEach(\n function(\n [name,value]\n ){\n\n const input =\n document.createElement(\n \"input\"\n );\n\n input.type =\n \"hidden\";\n\n input.name =\n name;\n\n input.value =\n String(\n value ?? \"\"\n );\n\n form.appendChild(\n input\n );\n }\n );\n\n document.body.appendChild(\n form\n );\n\n form.submit();\n\n setTimeout(\n function(){\n\n if(\n form.isConnected\n ){\n form.remove();\n }\n\n },\n 1500\n );\n\n setTimeout(\n function(){\n\n if(\n iframe.isConnected\n ){\n iframe.remove();\n }\n\n },\n GRADE_IFRAME_LIFETIME\n );\n}\n\n\n/* =========================================================\n ISOLATED DELETE POST\n========================================================= */\n\nfunction submitDeleteFormIsolated(data){\n\n const frameName =\n \"tp18-delete-frame-\" +\n Date.now() +\n \"-\" +\n (++deleteIframeCounter);\n\n const iframe =\n document.createElement(\n \"iframe\"\n );\n\n iframe.name =\n frameName;\n\n iframe.id =\n frameName;\n\n iframe.style.cssText =\n \"display:none!important;\" +\n \"width:0!important;\" +\n \"height:0!important;\" +\n \"border:0!important;\";\n\n document.body.appendChild(\n iframe\n );\n\n const form =\n document.createElement(\n \"form\"\n );\n\n form.method =\n \"POST\";\n\n form.action =\n DELETE_FORM.url;\n\n form.target =\n frameName;\n\n form.acceptCharset =\n \"UTF-8\";\n\n form.style.display =\n \"none\";\n\n const e =\n DELETE_FORM.entries;\n\n const fields = {};\n\n fields[e.submissionId] =\n data.submissionId;\n\n fields[e.studentCode] =\n data.studentCode;\n\n fields[e.studentName] =\n data.studentName;\n\n fields[e.workUrl] =\n data.workUrl;\n\n fields[e.submittedAt] =\n data.submittedAt;\n\n fields[e.actor] =\n ADMIN_GIVER;\n\n fields[e.reason] =\n data.reason;\n\n fields[e.action] =\n \"XÓA\";\n\n Object.entries(\n fields\n )\n .forEach(\n function(\n [name,value]\n ){\n\n const input =\n document.createElement(\n \"input\"\n );\n\n input.type =\n \"hidden\";\n\n input.name =\n name;\n\n input.value =\n String(\n value ?? \"\"\n );\n\n form.appendChild(\n input\n );\n }\n );\n\n document.body.appendChild(\n form\n );\n\n form.submit();\n\n setTimeout(\n function(){\n\n if(\n form.isConnected\n ){\n form.remove();\n }\n\n if(\n iframe.isConnected\n ){\n iframe.remove();\n }\n\n },\n DELETE_IFRAME_LIFETIME\n );\n}\n\n\n/* =========================================================\n GRADE SEND QUEUE\n========================================================= */\n\nfunction enqueueGradeSubmission(data){\n\n for(\n let i =\n gradeSendQueue.length - 1;\n i >= 0;\n i--\n ){\n\n if(\n gradeSendQueue[i]\n .submissionId ===\n data.submissionId\n ){\n\n gradeSendQueue.splice(\n i,\n 1\n );\n }\n }\n\n gradeSendQueue.push(\n data\n );\n\n processGradeSendQueue();\n}\n\n\nasync function processGradeSendQueue(){\n\n if(\n gradeSenderBusy\n ){\n return;\n }\n\n gradeSenderBusy =\n true;\n\n try{\n\n while(\n gradeSendQueue.length\n ){\n\n const data =\n gradeSendQueue.shift();\n\n const pending =\n pendingGrades.get(\n data.submissionId\n );\n\n if(!pending){\n continue;\n }\n\n if(\n pending.token !==\n data.token\n ){\n continue;\n }\n\n pending.state =\n \"sending\";\n\n try{\n\n submitGradeFormIsolated(\n data\n );\n\n pending.state =\n \"sent\";\n\n pending.sentAt =\n Date.now();\n\n setCardSyncState(\n data.submissionId,\n \"pending\"\n );\n\n }catch(error){\n\n console.error(\n \"Grade POST error:\",\n error\n );\n\n pending.state =\n \"send-error\";\n\n setCardGradeSendError(\n data.submissionId\n );\n\n showToast(\n UI.WARNING +\n \" Không thể khởi tạo gửi một bài chấm.\",\n \"error\",\n 4500\n );\n }\n\n await sleep(\n GRADE_SEND_GAP_MS\n );\n }\n\n }finally{\n\n gradeSenderBusy =\n false;\n\n scheduleGradeSyncCheck(\n GRADE_SYNC_FIRST_CHECK\n );\n }\n}\n\n\n/* =========================================================\n GIFT SUBMIT\n v17: ghi thẳng vào QuaTangGVCN qua Apps Script (kind \"gift\").\n - \"sent\" : đã ghi / đã có sẵn (máy chủ chống trùng)\n - \"notsent\" : máy chủ trả lỗi rõ ràng -> gửi Google Form như cũ\n - \"unknown\" : mất mạng / quá giờ -> kiểm tra Sheet trước, chưa có mới gửi Form\n========================================================= */\n\nconst GIFT_KIND_KEY = \"srcApiNoKind_v447\";\n\nfunction giftKindBlocked(){\n try{ const m=JSON.parse(window.localStorage.getItem(GIFT_KIND_KEY)||\"{}\"); return Date.now()<Number(m.gift||0); }catch(e){ return false; }\n}\n\nfunction blockGiftKind(){\n try{ const m=JSON.parse(window.localStorage.getItem(GIFT_KIND_KEY)||\"{}\"); m.gift=Date.now()+3600000; window.localStorage.setItem(GIFT_KIND_KEY,JSON.stringify(m)); }catch(e){}\n}\n\nasync function submitGiftDirect(data){\n\n const RS = getRewardCore();\n const url = RS && RS.CONFIG && RS.CONFIG.appsScriptUrl;\n\n if(!url || giftKindBlocked()){\n return \"notsent\";\n }\n\n const row = [\n \"\",\n \"Cá nhân\",\n data.code,\n data.group || \"\",\n \"Vật phẩm\",\n data.gift,\n \"\",\n 1,\n data.reason,\n ADMIN_GIVER,\n \"Đang hiệu lực\",\n data.course || \"\"\n ];\n\n for(let attempt=0;attempt<3;attempt++){\n\n const ac = new AbortController();\n const timer = setTimeout(function(){ ac.abort(); },20000);\n\n try{\n const res = await fetch(url,{\n method:\"POST\",\n body:JSON.stringify({action:\"append\",items:[{kind:\"gift\",row:row}]}),\n headers:{\"Content-Type\":\"text/plain;charset=utf-8\"},\n credentials:\"omit\",\n cache:\"no-store\",\n signal:ac.signal\n });\n const json = JSON.parse(await res.text());\n console.info(\"[Tặng quà] Apps Script:\",json);\n\n if(json && json.ok){\n const ok = (json.written && json.written.gift) || (json.dup && json.dup.gift);\n if(ok && typeof RS.markDataFresh === \"function\"){\n RS.markDataFresh(90000);\n }\n return ok ? \"sent\" : \"notsent\";\n }\n if(json && /lo[aạ]i d[uữ] li[eệ]u ghi kh[oô]ng h[oợ]p l[eệ]/i.test(String(json.error||\"\"))){\n blockGiftKind();\n return \"notsent\";\n }\n if(json && (json.busy || /đang bận/i.test(String(json.error||\"\"))) && attempt<2){\n await sleep(1500);\n continue;\n }\n return \"notsent\";\n\n }catch(error){\n console.warn(\"[Tặng quà] Ghi thẳng lỗi:\",error && error.message || error);\n if(attempt<1){ await sleep(1200); continue; }\n return \"unknown\";\n }finally{\n clearTimeout(timer);\n }\n }\n return \"notsent\";\n}\n\nfunction submitGiftForm(data){\n\n const e =\n GIFT_FORM.entries;\n\n const fields = {};\n\n fields[e.scope] =\n \"Cá nhân\";\n\n fields[e.studentCode] =\n data.code;\n\n fields[e.group] =\n data.group;\n\n fields[e.course] =\n data.course;\n\n fields[e.rewardType] =\n \"Vật phẩm\";\n\n fields[e.giftName] =\n data.gift;\n\n fields[e.gemstoneType] =\n \"\";\n\n fields[e.quantity] =\n 1;\n\n fields[e.reason] =\n data.reason;\n\n fields[e.giver] =\n ADMIN_GIVER;\n\n fields[e.status] =\n \"Đang hiệu lực\";\n\n postHiddenForm(\n GIFT_FORM.url,\n \"tp18-gift-frame\",\n fields\n );\n}\n\n\n/* =========================================================\n LOCAL GRADE\n========================================================= */\n\nfunction findLocalWorkById(\n submissionId\n){\n\n return(\n works.find(\n work =>\n work.__submissionId ===\n submissionId\n )\n ||\n null\n );\n}\n\n\nfunction applyLocalGrade(\n submissionId,\n score,\n comment\n){\n\n const item =\n findLocalWorkById(\n submissionId\n );\n\n if(!item){\n return;\n }\n\n item.__score =\n clean(score);\n\n item.__comment =\n clean(comment);\n\n item[\n normalize(\"Điểm số\")\n ] =\n clean(score);\n\n item[\n normalize(\"Nhận xét\")\n ] =\n clean(comment);\n\n item.__priority =\n calculatePriorityState(\n item\n );\n\n priorityCountCache =\n works.reduce(\n function(\n total,\n work\n ){\n\n return(\n total\n +\n (\n work.__priority &&\n work.__priority.pinned\n ?\n 1\n :\n 0\n )\n );\n\n },\n 0\n );\n\n buildSortedWorks();\n\n if(\n clean(\n adminSearchQuery\n )\n ){\n buildSearchResults();\n }\n\n if(\n selectedStudentName\n ){\n\n buildCurrentStudentResults(\n selectedStudentName\n );\n }\n}\n\n\n/* =========================================================\n CARD SYNC STATE\n========================================================= */\n\nfunction getVisibleCard(\n submissionId\n){\n\n const cards =\n document.querySelectorAll(\n \".tp18-card\"\n );\n\n for(\n const card\n of cards\n ){\n\n if(\n card.dataset.id ===\n submissionId\n ){\n return card;\n }\n }\n\n return null;\n}\n\n\nfunction setCardSyncState(\n submissionId,\n state\n){\n\n const card =\n getVisibleCard(\n submissionId\n );\n\n if(!card){\n return;\n }\n\n const result =\n card.querySelector(\n \".tp18-grade-result\"\n );\n\n if(!result){\n return;\n }\n\n card.classList.remove(\n \"grade-pending\",\n \"grade-synced\",\n \"grade-warning\"\n );\n\n if(\n state ===\n \"pending\"\n ){\n\n card.classList.add(\n \"grade-pending\"\n );\n\n result.className =\n \"tp18-grade-result wait\";\n\n result.innerHTML =\n '<span class=\"tp18-sync-dot\"></span>' +\n 'Đã gửi · Đang đồng bộ với Google';\n\n return;\n }\n\n if(\n state ===\n \"synced\"\n ){\n\n card.classList.add(\n \"grade-synced\"\n );\n\n result.className =\n \"tp18-grade-result ok\";\n\n result.innerHTML =\n '<span class=\"tp18-sync-dot\"></span>' +\n CHAR.CHECK +\n ' Đã đồng bộ';\n\n setTimeout(\n function(){\n\n const currentCard =\n getVisibleCard(\n submissionId\n );\n\n if(\n currentCard\n ){\n\n currentCard.classList.remove(\n \"grade-synced\"\n );\n }\n\n },\n 4000\n );\n }\n}\n\n\nfunction setCardWaitingGoogle(\n submissionId\n){\n\n const card =\n getVisibleCard(\n submissionId\n );\n\n if(!card){\n return;\n }\n\n card.classList.remove(\n \"grade-synced\",\n \"grade-warning\"\n );\n\n card.classList.add(\n \"grade-pending\"\n );\n\n const result =\n card.querySelector(\n \".tp18-grade-result\"\n );\n\n if(!result){\n return;\n }\n\n result.className =\n \"tp18-grade-result wait\";\n\n result.innerHTML =\n '<span class=\"tp18-sync-dot\"></span>' +\n 'Đã gửi Form · Đang chờ Google cập nhật dữ liệu';\n}\n\n\nfunction setCardGradeSendError(\n submissionId\n){\n\n const card =\n getVisibleCard(\n submissionId\n );\n\n if(!card){\n return;\n }\n\n card.classList.remove(\n \"grade-pending\",\n \"grade-synced\"\n );\n\n card.classList.add(\n \"grade-warning\"\n );\n\n const result =\n card.querySelector(\n \".tp18-grade-result\"\n );\n\n if(!result){\n return;\n }\n\n result.className =\n \"tp18-grade-result error\";\n\n setText(\n result,\n\n UI.WARNING +\n \" Không thể khởi tạo gửi Form. Hãy bấm Xác nhận chấm lại.\"\n );\n}\n\n\n/* =========================================================\n CENTRAL GRADE SYNC\n========================================================= */\n\nfunction scheduleGradeSyncCheck(\n delay\n){\n\n if(\n !pendingGrades.size\n ){\n return;\n }\n\n if(\n gradeSyncTimer\n ){\n return;\n }\n\n gradeSyncTimer =\n setTimeout(\n function(){\n\n gradeSyncTimer =\n null;\n\n runGradeSyncCheck();\n\n },\n Number(\n delay ||\n GRADE_SYNC_INTERVAL\n )\n );\n}\n\n\nasync function runGradeSyncCheck(){\n\n if(\n gradeSyncRunning\n ||\n !pendingGrades.size\n ){\n return;\n }\n\n gradeSyncRunning =\n true;\n\n try{\n\n const freshResult =\n await loadWorkData();\n\n sourceName =\n freshResult.source;\n\n const synced =\n reconcilePendingGradesWithRawData(\n freshResult.data,\n true\n );\n\n if(\n synced > 0\n ){\n\n updateStatus();\n\n renderPagers();\n }\n\n const now =\n Date.now();\n\n let hasActivePending =\n false;\n\n pendingGrades.forEach(\n function(\n pending,\n submissionId\n ){\n\n if(\n pending.state ===\n \"send-error\"\n ){\n return;\n }\n\n if(\n pending.state ===\n \"queued\" ||\n pending.state ===\n \"sending\"\n ){\n\n hasActivePending =\n true;\n\n return;\n }\n\n const age =\n now -\n (\n pending.sentAt\n ||\n pending.createdAt\n ||\n now\n );\n\n if(\n age <\n GRADE_SYNC_WARNING_AFTER\n ){\n\n hasActivePending =\n true;\n\n pending.state =\n \"sent\";\n\n setCardSyncState(\n submissionId,\n \"pending\"\n );\n\n return;\n }\n\n pending.state =\n \"waiting-google\";\n\n setCardWaitingGoogle(\n submissionId\n );\n\n if(\n age <\n GRADE_SYNC_ACTIVE_WINDOW\n ){\n\n hasActivePending =\n true;\n }\n }\n );\n\n updateStatus();\n\n renderPagers();\n\n if(\n pendingGrades.size &&\n hasActivePending\n ){\n\n scheduleGradeSyncCheck(\n GRADE_SYNC_INTERVAL\n );\n }\n\n }catch(error){\n\n console.warn(\n \"Grade central sync:\",\n error\n );\n\n if(\n pendingGrades.size\n ){\n\n scheduleGradeSyncCheck(\n GRADE_SYNC_INTERVAL\n );\n }\n\n }finally{\n\n gradeSyncRunning =\n false;\n }\n}\n\n\n/* =========================================================\n GIFT VERIFY\n========================================================= */\n\nfunction giftExists(\n data,\n expected\n){\n\n return data.some(\n function(row){\n\n return(\n\n normalize(\n column(\n row,\n [\"Mã học viên\"]\n )\n )\n ===\n normalize(\n expected.code\n )\n\n &&\n\n normalize(\n column(\n row,\n [\"Tên vật phẩm\"]\n )\n )\n ===\n normalize(\n expected.gift\n )\n\n &&\n\n extractGiftSubmissionId(\n column(\n row,\n [\"Lý do tặng\"]\n )\n )\n ===\n expected.submissionId\n\n &&\n\n isActiveTeacherGift(\n row\n )\n );\n }\n );\n}\n\n\nasync function verifyGift(\n expected\n){\n\n for(\n let attempt=0;\n attempt<GIFT_VERIFY_TRIES;\n attempt++\n ){\n\n await sleep(\n GIFT_VERIFY_INTERVAL\n );\n\n try{\n\n const fresh =\n csvObjects(\n await fetchCSV(\n URLS.teacherGifts\n )\n );\n\n if(\n giftExists(\n fresh,\n expected\n )\n ){\n\n teacherGiftData =\n fresh;\n\n buildTeacherGiftStudentIndex();\n\n /*\n QuaTangGVCN chỉ xác nhận giao dịch.\n Quyền sở hữu cuối cùng phải được Core replay lại.\n */\n await loadCanonicalStudentAssets(\n expected.code,\n true\n );\n\n buildPriorityCache();\n\n buildSortedWorks();\n\n if(\n clean(\n adminSearchQuery\n )\n ){\n buildSearchResults();\n }\n\n return true;\n }\n\n }catch(error){\n\n console.warn(\n \"Gift verify:\",\n error\n );\n }\n }\n\n return false;\n}\n\n\n/* =========================================================\n HANDLE GIFT\n========================================================= */\n\nasync function handleGiveGift(button){\n\n if(!ADMIN_MODE){\n return;\n }\n\n const card =\n button.closest(\n \".tp18-card\"\n );\n\n const select =\n card.querySelector(\n \".tp18-gift-select\"\n );\n\n const reasonInput =\n card.querySelector(\n \".tp18-gift-reason\"\n );\n\n const result =\n card.querySelector(\n \".tp18-give-result\"\n );\n\n const gift =\n clean(\n select.value\n );\n\n if(!gift){\n\n result.className =\n \"tp18-give-result error\";\n\n setText(\n result,\n \"Hãy chọn vật phẩm.\"\n );\n\n return;\n }\n\n if(\n !findCatalogGift(\n gift\n )\n ){\n\n result.className =\n \"tp18-give-result error\";\n\n setText(\n result,\n \"Không tìm thấy vật phẩm trong sheet QuaTang.\"\n );\n\n return;\n }\n\n const reason =\n clean(\n reasonInput.value\n )\n ||\n \"GVCN tặng từ trang chấm bài\";\n\n if(\n !window.confirm(\n\n \"Tặng cho: \" +\n card.dataset.name +\n\n \"\\nVật phẩm: \" +\n gift +\n\n \"\\nSố lượng: 1\" +\n\n \"\\n\\nXác nhận tặng?\"\n )\n ){\n return;\n }\n\n button.disabled =\n true;\n\n setText(\n button,\n \"Đang gửi...\"\n );\n\n result.className =\n \"tp18-give-result wait\";\n\n setText(\n result,\n \"Đang gửi và kiểm tra QuaTangGVCN...\"\n );\n\n const expected = {\n\n code:\n card.dataset.code,\n\n gift:\n gift,\n\n submissionId:\n card.dataset.id\n };\n\n try{\n\n const giftData = {\n\n code:\n card.dataset.code,\n\n group:\n card.dataset.group,\n\n course:\n card.dataset.course,\n\n gift:\n gift,\n\n reason:\n buildGiftReason(\n reason,\n card.dataset.id\n )\n };\n\n /* v17: Apps Script trước, lỗi mới dùng Google Form */\n const mode =\n await submitGiftDirect(\n giftData\n );\n\n if(mode === \"notsent\"){\n submitGiftForm(\n giftData\n );\n }\n\n let success =\n await verifyGift(\n expected\n );\n\n if(!success && mode === \"unknown\"){\n submitGiftForm(\n giftData\n );\n success =\n await verifyGift(\n expected\n );\n }\n\n if(!success){\n\n result.className =\n \"tp18-give-result error\";\n\n setText(\n result,\n\n CHAR.CROSS +\n \" Chưa xác minh được \" +\n gift +\n \" trong QuaTangGVCN.\"\n );\n\n return;\n }\n\n result.className =\n \"tp18-give-result ok\";\n\n setText(\n result,\n\n CHAR.CHECK +\n \" Đã tặng 1 \" +\n CHAR.MULTIPLY +\n \" \" +\n gift\n );\n\n refreshGiftDisplay(\n card\n );\n\n updateStatus();\n\n showToast(\n \"Đã tặng quà\",\n \"success\"\n );\n\n }catch(error){\n\n result.className =\n \"tp18-give-result error\";\n\n setText(\n result,\n\n \"Không thể tặng quà: \" +\n (\n error.message ||\n error\n )\n );\n\n }finally{\n\n button.disabled =\n false;\n\n setText(\n button,\n UI.GIFT +\n \" Xác nhận tặng\"\n );\n }\n}\n\n\n/* =========================================================\n REFRESH GIFT DISPLAY\n========================================================= */\n\nfunction refreshGiftDisplay(\n card\n){\n\n const item =\n works.find(\n work =>\n work.__submissionId ===\n card.dataset.id\n );\n\n if(!item){\n return;\n }\n\n teacherGiftWorkCache.delete(\n item.__submissionId\n );\n\n const box =\n card.querySelector(\n \".tp18-gifts-owned\"\n );\n\n const icons =\n card.querySelector(\n \".tp18-gift-icons\"\n );\n\n if(\n !box ||\n !icons\n ){\n return;\n }\n\n const gifts =\n getTeacherGiftsForWork(\n item\n );\n\n icons.innerHTML =\n \"\";\n\n if(\n !gifts.length\n ){\n\n box.classList.remove(\n \"on\"\n );\n\n return;\n }\n\n gifts.forEach(\n gift =>\n icons.appendChild(\n createGiftOwnedNode(\n gift\n )\n )\n );\n\n box.classList.add(\n \"on\"\n );\n}\n\n\n/* =========================================================\n SCORE VALIDATION\n========================================================= */\n\nfunction validateScore(value){\n\n const text =\n clean(value)\n .replace(\n \",\",\n \".\"\n );\n\n if(!text){\n\n return{\n valid:true,\n value:\"\"\n };\n }\n\n if(\n !/^\\d+(\\.\\d+)?$/\n .test(text)\n ){\n\n return{\n valid:false,\n message:\n \"Điểm phải là một số.\"\n };\n }\n\n const number =\n Number(text);\n\n if(\n number < 0 ||\n number > 10\n ){\n\n return{\n valid:false,\n message:\n \"Điểm phải từ 0 đến 10.\"\n };\n }\n\n return{\n valid:true,\n value:String(number)\n };\n}\n\n\nfunction updateAdminScoreColor(\n input\n){\n\n if(!input){\n return;\n }\n\n const hasScore =\n clean(\n input.value\n )\n !==\n \"\";\n\n input.classList.toggle(\n \"graded\",\n hasScore\n );\n\n input.classList.toggle(\n \"ungraded\",\n !hasScore\n );\n}\n\n\n/* =========================================================\n HANDLE GRADE\n========================================================= */\n\nfunction handleGrade(button){\n\n if(!ADMIN_MODE){\n return;\n }\n\n const card =\n button.closest(\n \".tp18-card\"\n );\n\n if(!card){\n return;\n }\n\n if(\n card.dataset.grading ===\n \"1\"\n ){\n return;\n }\n\n const scoreInput =\n card.querySelector(\n \".tp18-score-input\"\n );\n\n const commentInput =\n card.querySelector(\n \".tp18-comment-input\"\n );\n\n const resultBox =\n card.querySelector(\n \".tp18-grade-result\"\n );\n\n const validatedScore =\n validateScore(\n scoreInput.value\n );\n\n if(\n !validatedScore.valid\n ){\n\n resultBox.className =\n \"tp18-grade-result error\";\n\n setText(\n resultBox,\n validatedScore.message\n );\n\n return;\n }\n\n const newScore =\n clean(\n validatedScore.value\n );\n\n const newComment =\n clean(\n commentInput.value\n );\n\n const oldScore =\n clean(\n card.dataset.originalScore\n )\n .replace(\n \",\",\n \".\"\n );\n\n const oldComment =\n clean(\n card.dataset.originalComment\n );\n\n if(\n newScore ===\n oldScore\n\n &&\n\n newComment ===\n oldComment\n ){\n\n resultBox.className =\n \"tp18-grade-result error\";\n\n setText(\n resultBox,\n \"Điểm và nhận xét chưa có thay đổi.\"\n );\n\n return;\n }\n\n if(\n !window.confirm(\n\n \"Học viên: \" +\n card.dataset.name +\n\n \"\\nĐiểm: \" +\n (\n newScore ||\n \"để trống\"\n ) +\n\n \"\\nNhận xét: \" +\n (\n newComment ||\n \"để trống\"\n ) +\n\n \"\\n\\nXác nhận chấm bài?\"\n )\n ){\n return;\n }\n\n card.dataset.grading =\n \"1\";\n\n button.disabled =\n true;\n\n setText(\n button,\n \"Đang ghi nhận...\"\n );\n\n try{\n\n const gradeToken =\n Date.now()\n +\n \"-\"\n +\n Math.random()\n .toString(36)\n .slice(2);\n\n pendingGrades.set(\n\n card.dataset.id,\n\n {\n submissionId:\n card.dataset.id,\n\n score:\n newScore,\n\n comment:\n newComment,\n\n createdAt:\n Date.now(),\n\n sentAt:\n 0,\n\n state:\n \"queued\",\n\n token:\n gradeToken\n }\n );\n\n applyLocalGrade(\n card.dataset.id,\n newScore,\n newComment\n );\n\n card.dataset.originalScore =\n newScore;\n\n card.dataset.originalComment =\n newComment;\n\n card.classList.remove(\n \"grade-warning\",\n \"grade-synced\"\n );\n\n card.classList.add(\n \"grade-pending\"\n );\n\n resultBox.className =\n \"tp18-grade-result wait\";\n\n resultBox.innerHTML =\n '<span class=\"tp18-sync-dot\"></span>' +\n 'Đã ghi nhận · Đang xếp hàng gửi';\n\n enqueueGradeSubmission({\n\n submissionId:\n card.dataset.id,\n\n studentName:\n card.dataset.name,\n\n studentCode:\n card.dataset.code,\n\n workUrl:\n card.dataset.work,\n\n score:\n newScore,\n\n comment:\n newComment,\n\n token:\n gradeToken\n });\n\n showToast(\n\n CHAR.CHECK +\n \" Đã ghi nhận bài chấm — đang gửi tới Google.\",\n\n \"success\",\n\n 2000\n );\n\n card.classList.remove(\n \"priority\"\n );\n\n const priorityLabel =\n card.querySelector(\n \".tp18-priority-label\"\n );\n\n if(priorityLabel){\n priorityLabel.remove();\n }\n\n updateStatus();\n updateSearchSummary();\n renderPagers();\n\n }catch(error){\n\n console.error(\n \"Grade submit error:\",\n error\n );\n\n resultBox.className =\n \"tp18-grade-result error\";\n\n setText(\n resultBox,\n\n \"Không thể ghi nhận bài chấm: \" +\n (\n error.message ||\n error\n )\n );\n\n showToast(\n \"Gửi bài chấm thất bại\",\n \"error\"\n );\n\n }finally{\n\n card.dataset.grading =\n \"0\";\n\n button.disabled =\n false;\n\n setText(\n button,\n CHAR.CHECK +\n \" Xác nhận chấm\"\n );\n }\n}\n\n\n/* =========================================================\n VERIFY DELETE\n========================================================= */\n\nasync function verifyDelete(\n submissionId\n){\n\n await sleep(\n DELETE_VERIFY_FIRST_DELAY\n );\n\n for(\n let attempt=1;\n attempt<=DELETE_VERIFY_TRIES;\n attempt++\n ){\n\n try{\n\n const fresh =\n csvObjects(\n await fetchCSV(\n URLS.deleteLog\n )\n );\n\n if(\n deleteLogHasSubmission(\n fresh,\n submissionId\n )\n ){\n\n deleteLogData =\n fresh;\n\n buildDeletedSubmissionIds(\n fresh\n );\n\n return true;\n }\n\n }catch(error){\n\n console.warn(\n \"Delete verify \" +\n attempt +\n \":\",\n error\n );\n }\n\n if(\n attempt <\n DELETE_VERIFY_TRIES\n ){\n\n await sleep(\n DELETE_VERIFY_INTERVAL\n );\n }\n }\n\n return false;\n}\n\n\n/* =========================================================\n HANDLE DELETE\n========================================================= */\n\nasync function handleDelete(button){\n\n if(!ADMIN_MODE){\n return;\n }\n\n const card =\n button.closest(\n \".tp18-card\"\n );\n\n if(!card){\n return;\n }\n\n const submissionId =\n clean(\n card.dataset.id\n );\n\n if(!submissionId){\n\n showToast(\n \"Không xác định được Mã bài nộp.\",\n \"error\",\n 4500\n );\n\n return;\n }\n\n if(\n pendingDeletes.has(\n submissionId\n )\n ){\n return;\n }\n\n const deleteDate =\n toGoogleFormDate(\n card.dataset.submittedAt\n );\n\n if(!deleteDate){\n\n showToast(\n \"Không chuyển đổi được ngày nộp bài để gửi Form xoá.\",\n \"error\",\n 5000\n );\n\n return;\n }\n\n const defaultReason =\n \"Bài nộp không hợp lệ\";\n\n const reasonInput =\n window.prompt(\n\n \"Lý do xoá bài của:\\n\" +\n card.dataset.name,\n\n defaultReason\n );\n\n if(\n reasonInput ===\n null\n ){\n return;\n }\n\n const reason =\n clean(\n reasonInput\n )\n ||\n defaultReason;\n\n if(\n !window.confirm(\n\n \"XÁC NHẬN XOÁ BÀI\\n\\n\" +\n\n \"Học viên: \" +\n card.dataset.name +\n\n \"\\nMã học viên: \" +\n card.dataset.code +\n\n \"\\nNgày nộp: \" +\n deleteDate +\n\n \"\\nLý do: \" +\n reason +\n\n \"\\n\\n\" +\n\n \"Bài chỉ bị loại khỏi hệ thống hiển thị.\\n\" +\n \"Dữ liệu gốc không bị xoá vật lý.\"\n )\n ){\n return;\n }\n\n const result =\n card.querySelector(\n \".tp18-delete-result\"\n );\n\n pendingDeletes.set(\n\n submissionId,\n\n {\n state:\n \"sending\",\n\n createdAt:\n Date.now()\n }\n );\n\n button.disabled =\n true;\n\n button.classList.add(\n \"pending\"\n );\n\n card.classList.add(\n \"delete-checking\"\n );\n\n setText(\n button,\n \"…\"\n );\n\n if(result){\n\n result.className =\n \"tp18-delete-result on\";\n\n setText(\n result,\n \"Đang lưu vào XoaBai...\"\n );\n }\n\n showToast(\n \"Đang gửi lệnh xoá tới Google...\",\n \"wait\",\n 0\n );\n\n try{\n\n submitDeleteFormIsolated({\n\n submissionId:\n submissionId,\n\n studentCode:\n card.dataset.code,\n\n studentName:\n card.dataset.name,\n\n workUrl:\n card.dataset.work,\n\n submittedAt:\n deleteDate,\n\n reason:\n reason\n });\n\n pendingDeletes.get(\n submissionId\n ).state =\n \"verifying\";\n\n const success =\n await verifyDelete(\n submissionId\n );\n\n if(!success){\n\n pendingDeletes.delete(\n submissionId\n );\n\n button.disabled =\n false;\n\n button.classList.remove(\n \"pending\"\n );\n\n card.classList.remove(\n \"delete-checking\"\n );\n\n /*\n NÚT XOÁ = ×\n */\n setText(\n button,\n CHAR.MULTIPLY\n );\n\n if(result){\n\n result.className =\n \"tp18-delete-result on error\";\n\n setText(\n result,\n \"Chưa thấy dữ liệu trong XoaBai. Bài chưa bị xoá.\"\n );\n\n setTimeout(\n function(){\n\n if(\n result.isConnected\n ){\n result.classList.remove(\n \"on\"\n );\n }\n\n },\n 6000\n );\n }\n\n showToast(\n UI.WARNING +\n \" Chưa xác minh được lệnh xoá trong XoaBai.\",\n \"warning\",\n 5000\n );\n\n return;\n }\n\n pendingDeletes.delete(\n submissionId\n );\n\n pendingGrades.delete(\n submissionId\n );\n\n deletedSubmissionIds.add(\n submissionId\n );\n\n works =\n works.filter(\n item =>\n item.__submissionId !==\n submissionId\n );\n\n /*\n works hiện đã loại bài vừa xóa.\n Force refresh để bài XoaBai không còn ảnh hưởng\n tới điểm thưởng/tài sản trong Reward Core.\n */\n canonicalAssetMap.delete(\n normalize(\n card.dataset.code\n )\n );\n\n await loadCanonicalStudentAssets(\n card.dataset.code,\n true\n )\n .catch(function(error){\n console.warn(\n \"Không refresh được Reward Core sau XoaBai:\",\n error\n );\n });\n\n buildAllRuntimeCaches();\n\n clampCurrentPage();\n\n restoreCurrentView();\n\n updateStatus();\n\n updateSearchSummary();\n\n showToast(\n CHAR.CHECK +\n \" Đã lưu vào XoaBai và xoá bài khỏi trang.\",\n \"success\",\n 3500\n );\n\n }catch(error){\n\n console.error(\n \"Delete error:\",\n error\n );\n\n pendingDeletes.delete(\n submissionId\n );\n\n button.disabled =\n false;\n\n button.classList.remove(\n \"pending\"\n );\n\n card.classList.remove(\n \"delete-checking\"\n );\n\n /*\n NÚT XOÁ = ×\n */\n setText(\n button,\n CHAR.MULTIPLY\n );\n\n if(result){\n\n result.className =\n \"tp18-delete-result on error\";\n\n setText(\n result,\n \"Không thể gửi lệnh xoá.\"\n );\n }\n\n showToast(\n \"Không thể gửi lệnh xoá.\",\n \"error\",\n 4500\n );\n }\n}\n\n\n/* =========================================================\n WORK IMAGE\n========================================================= */\n\nfunction prepareWorkImage(\n image,\n source\n){\n\n applyImageFallback(\n\n image,\n\n source,\n\n function(broken){\n\n const fallback =\n document.createElement(\n \"div\"\n );\n\n fallback.className =\n \"tp18-no-image\";\n\n const icon =\n document.createElement(\n \"div\"\n );\n\n setText(\n icon,\n UI.IMAGE\n );\n\n const small =\n document.createElement(\n \"small\"\n );\n\n setText(\n small,\n \"Không tải được ảnh\"\n );\n\n fallback.appendChild(\n icon\n );\n\n fallback.appendChild(\n small\n );\n\n const link =\n broken.closest(\n \".tp18-work-link\"\n );\n\n if(link){\n\n link.replaceWith(\n fallback\n );\n\n }else{\n\n broken.replaceWith(\n fallback\n );\n }\n }\n );\n}\n\n\n/* =========================================================\n CREATE CARD\n========================================================= */\n\nfunction createCard(\n item,\n displayNumber\n){\n\n const name =\n item.__name;\n\n const code =\n item.__code;\n\n const group =\n item.__group;\n\n const course =\n item.__course;\n\n const submittedAt =\n item.__timestampText;\n\n const score =\n item.__score;\n\n const hasScore =\n score !== \"\";\n\n const comment =\n item.__comment;\n\n const rawWork =\n item.__rawWork;\n\n const workUrl =\n item.__workUrl;\n\n const image =\n item.__image;\n\n const submissionId =\n item.__submissionId;\n\n const beasts =\n item.__beasts\n ||\n [];\n\n const priority =\n item.__priority\n ||\n {\n pinned:false,\n needsScore:false,\n needsComment:false,\n label:\"\"\n };\n\n const teacherGifts =\n getTeacherGiftsForWork(\n item\n );\n\n const pending =\n pendingGrades.get(\n submissionId\n );\n\n const card =\n document.createElement(\n \"article\"\n );\n\n card.className =\n \"tp18-card\";\n\n if(\n priority.pinned\n ){\n\n card.classList.add(\n \"priority\"\n );\n }\n\n if(\n beasts.length\n ){\n\n card.classList.add(\n \"has-beast\"\n );\n }\n\n if(pending){\n\n if(\n pending.state ===\n \"send-error\"\n ){\n\n card.classList.add(\n \"grade-warning\"\n );\n\n }else{\n\n card.classList.add(\n \"grade-pending\"\n );\n }\n }\n\n Object.assign(\n card.dataset,\n {\n\n id:\n submissionId,\n\n name:\n name,\n\n code:\n code,\n\n group:\n group,\n\n course:\n course,\n\n work:\n workUrl === \"#\"\n ?\n \"\"\n :\n workUrl,\n\n submittedAt:\n submittedAt,\n\n originalScore:\n score,\n\n originalComment:\n comment,\n\n grading:\n \"0\"\n }\n );\n\n const chip =\n function(\n text,\n css\n ){\n\n return(\n '<span class=\"tp18-chip ' +\n (\n css ||\n \"\"\n ) +\n '\">' +\n escapeHTML(\n decodeNumericEntities(\n text\n )\n ) +\n \"</span>\"\n );\n };\n\n let chips =\n \"\";\n\n chips +=\n chip(\n UI.PEOPLE +\n \" Tổ \" +\n displayValue(group)\n );\n\n chips +=\n chip(\n UI.GRADUATION +\n \" Khóa \" +\n displayValue(course)\n );\n\n if(\n ADMIN_MODE &&\n code\n ){\n\n chips +=\n chip(\n \"Mã: \" +\n code\n );\n }\n\n if(\n ADMIN_MODE &&\n priority.needsScore\n ){\n\n chips +=\n chip(\n\n UI.PIN +\n \" Chờ chấm điểm\",\n\n \"priority\"\n );\n }\n\n if(\n ADMIN_MODE &&\n priority.needsComment\n ){\n\n chips +=\n chip(\n\n UI.PIN +\n \" Chờ nhận xét\",\n\n \"priority\"\n );\n }\n\n let initialSyncHTML =\n \"\";\n\n if(pending){\n\n if(\n pending.state ===\n \"send-error\"\n ){\n\n initialSyncHTML =\n `\n <div class=\"tp18-grade-result error\">\n ${escapeHTML(UI.WARNING)}\n Không thể khởi tạo gửi Form.\n Hãy bấm Xác nhận chấm lại.\n </div>\n `;\n\n }else if(\n pending.state ===\n \"waiting-google\"\n ){\n\n initialSyncHTML =\n `\n <div class=\"tp18-grade-result wait\">\n <span class=\"tp18-sync-dot\"></span>\n Đã gửi Form · Đang chờ Google cập nhật dữ liệu\n </div>\n `;\n\n }else if(\n pending.state ===\n \"queued\"\n ){\n\n initialSyncHTML =\n `\n <div class=\"tp18-grade-result wait\">\n <span class=\"tp18-sync-dot\"></span>\n Đã ghi nhận · Đang xếp hàng gửi\n </div>\n `;\n\n }else if(\n pending.state ===\n \"sending\"\n ){\n\n initialSyncHTML =\n `\n <div class=\"tp18-grade-result wait\">\n <span class=\"tp18-sync-dot\"></span>\n Đang gửi tới Google Form\n </div>\n `;\n\n }else{\n\n initialSyncHTML =\n `\n <div class=\"tp18-grade-result wait\">\n <span class=\"tp18-sync-dot\"></span>\n Đã gửi · Đang đồng bộ với Google\n </div>\n `;\n }\n\n }else{\n\n initialSyncHTML =\n `<div class=\"tp18-grade-result\"></div>`;\n }\n\n let bodyHTML =\n \"\";\n\n if(ADMIN_MODE){\n\n bodyHTML = `\n\n <div class=\"tp18-score\">\n\n <div class=\"tp18-score-row\">\n\n <span class=\"tp18-score-label\">\n Điểm GVCN\n </span>\n\n <input\n class=\"tp18-score-input ${hasScore ? \"graded\" : \"ungraded\"}\"\n type=\"text\"\n inputmode=\"decimal\"\n value=\"${escapeHTML(score)}\"\n placeholder=\"0-10\"\n >\n\n </div>\n\n </div>\n\n\n <div class=\"tp18-comment\">\n\n <div class=\"tp18-comment-title\">\n Nhận xét GVCN\n </div>\n\n <textarea\n class=\"tp18-comment-input\"\n placeholder=\"Nhập nhận xét...\"\n >${escapeHTML(comment)}</textarea>\n\n </div>\n\n\n ${initialSyncHTML}\n\n\n <!-- =================================================\n GVCN TẶNG QUÀ - THU GỌN\n ================================================== -->\n\n <div class=\"tp18-admin-gift\">\n\n <button\n class=\"tp18-admin-gift-toggle\"\n type=\"button\"\n aria-expanded=\"false\"\n >\n\n <span class=\"tp18-admin-gift-toggle-text\">\n ${escapeHTML(UI.GIFT)}\n GVCN tặng quà\n </span>\n\n <span class=\"tp18-admin-gift-arrow\">\n ▾\n </span>\n\n </button>\n\n\n <div class=\"tp18-admin-gift-body\">\n\n <select class=\"tp18-gift-select\">\n ${giftOptionsCache}\n </select>\n\n <div class=\"tp18-gift-meta\">\n Linh Thú không tự thay đổi vật phẩm. GVCN tự lựa chọn.\n </div>\n\n <input\n class=\"tp18-gift-reason\"\n type=\"text\"\n placeholder=\"Lý do tặng...\"\n >\n\n <button\n type=\"button\"\n class=\"tp18-give\"\n >\n Xác nhận tặng\n </button>\n\n <div class=\"tp18-give-result\"></div>\n\n </div>\n\n </div>\n\n\n <div class=\"tp18-footer\">\n\n <button\n class=\"tp18-grade\"\n type=\"button\"\n >\n Xác nhận chấm\n </button>\n\n <div class=\"tp18-time\">\n ${escapeHTML(\n submittedAt ||\n CHAR.EM_DASH\n )}\n </div>\n\n </div>\n `;\n\n }else{\n\n bodyHTML = `\n\n <div class=\"tp18-public-data\">\n\n <div class=\"tp18-public-box\">\n\n <div class=\"tp18-public-label\">\n Điểm số\n </div>\n\n <div\n class=\"tp18-public-score ${\n hasScore\n ?\n \"graded\"\n :\n \"ungraded\"\n }\"\n >\n ${\n hasScore\n ?\n escapeHTML(score)\n :\n \"Chưa chấm\"\n }\n </div>\n\n </div>\n\n\n <div class=\"tp18-public-box\">\n\n <div class=\"tp18-public-label\">\n Nhận xét GVCN\n </div>\n\n <div class=\"tp18-public-comment\">${\n escapeHTML(\n comment ||\n \"Chưa có nhận xét\"\n )\n }</div>\n\n </div>\n\n </div>\n\n\n <div class=\"tp18-footer\">\n\n <div></div>\n\n <div class=\"tp18-time\">\n ${escapeHTML(\n submittedAt ||\n CHAR.EM_DASH\n )}\n </div>\n\n </div>\n `;\n }\n\n card.innerHTML = `\n\n ${\n ADMIN_MODE &&\n priority.pinned\n ?\n `\n <div class=\"tp18-priority-label\">\n ${escapeHTML(\n UI.PIN +\n \" \" +\n priority.label\n )}\n </div>\n `\n :\n \"\"\n }\n\n\n <div class=\"tp18-image-box\">\n\n <div class=\"tp18-number\">\n ${Number(displayNumber)}\n </div>\n\n\n ${\n ADMIN_MODE\n ?\n `\n <button\n class=\"tp18-delete\"\n type=\"button\"\n title=\"Xoá bài\"\n aria-label=\"Xoá bài\"\n >\n ${escapeHTML(CHAR.MULTIPLY)}\n </button>\n\n <div class=\"tp18-delete-result\"></div>\n `\n :\n \"\"\n }\n\n\n ${\n image\n ?\n `\n <a\n class=\"tp18-work-link\"\n href=\"${escapeHTML(workUrl)}\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n >\n\n <img\n class=\"tp18-work-image\"\n src=\"${escapeHTML(image)}\"\n alt=\"Tác phẩm của ${escapeHTML(name)}\"\n loading=\"lazy\"\n decoding=\"async\"\n fetchpriority=\"low\"\n >\n\n </a>\n `\n :\n `\n <div class=\"tp18-no-image\">\n\n <div>\n ${escapeHTML(UI.IMAGE)}\n </div>\n\n <small>\n Không có ảnh\n </small>\n\n </div>\n `\n }\n\n </div>\n\n\n <div class=\"tp18-info\">\n\n <div class=\"tp18-name\">\n ${escapeHTML(\n name ||\n \"Học viên\"\n )}\n </div>\n\n\n <div class=\"tp18-meta\">\n ${chips}\n </div>\n\n\n <div\n class=\"tp18-gifts-owned ${\n teacherGifts.length\n ?\n \"on\"\n :\n \"\"\n }\"\n >\n\n <div class=\"tp18-gifts-owned-title\">\n Quà GVCN\n </div>\n\n <div class=\"tp18-gift-icons\"></div>\n\n </div>\n\n\n ${bodyHTML}\n\n </div>\n `;\n\n if(ADMIN_MODE){\n\n const gradeButton =\n card.querySelector(\n \".tp18-grade\"\n );\n\n if(gradeButton){\n\n setText(\n gradeButton,\n\n CHAR.CHECK +\n \" Xác nhận chấm\"\n );\n }\n\n const giftButton =\n card.querySelector(\n \".tp18-give\"\n );\n\n if(giftButton){\n\n setText(\n giftButton,\n\n UI.GIFT +\n \" Xác nhận tặng\"\n );\n }\n\n const deleteButton =\n card.querySelector(\n \".tp18-delete\"\n );\n\n if(deleteButton){\n\n /*\n NÚT XOÁ = ×\n */\n setText(\n deleteButton,\n CHAR.MULTIPLY\n );\n }\n }\n\n const workImage =\n card.querySelector(\n \".tp18-work-image\"\n );\n\n if(workImage){\n\n prepareWorkImage(\n workImage,\n rawWork\n );\n }\n\n if(\n beasts.length\n ){\n\n const beastStrip =\n document.createElement(\n \"div\"\n );\n\n beastStrip.className =\n \"tp18-beasts\";\n\n beasts\n .slice(0,8)\n .forEach(\n beast =>\n beastStrip.appendChild(\n createBeastNode(\n beast\n )\n )\n );\n\n card.querySelector(\n \".tp18-image-box\"\n )\n .insertAdjacentElement(\n \"afterend\",\n beastStrip\n );\n }\n\n if(\n teacherGifts.length\n ){\n\n const giftStrip =\n card.querySelector(\n \".tp18-gift-icons\"\n );\n\n teacherGifts.forEach(\n gift =>\n giftStrip.appendChild(\n createGiftOwnedNode(\n gift\n )\n )\n );\n }\n\n return card;\n}\n\n\n/* =========================================================\n GIFT META\n========================================================= */\n\nfunction updateGiftMeta(\n select\n){\n\n if(!ADMIN_MODE){\n return;\n }\n\n const card =\n select.closest(\n \".tp18-card\"\n );\n\n const box =\n card.querySelector(\n \".tp18-gift-meta\"\n );\n\n const gift =\n clean(\n select.value\n );\n\n if(!gift){\n\n setText(\n box,\n \"Linh Thú không tự thay đổi vật phẩm. GVCN tự lựa chọn.\"\n );\n\n return;\n }\n\n const item =\n findCatalogGift(\n gift\n );\n\n if(!item){\n\n setText(\n box,\n \"Không tìm thấy vật phẩm trong QuaTang.\"\n );\n\n return;\n }\n\n const rarity =\n displayValue(\n column(\n item,\n [\"Độ hiếm\"]\n )\n );\n\n const description =\n clean(\n column(\n item,\n [\"Mô tả\"]\n )\n );\n\n setText(\n box,\n\n rarity\n +\n (\n description\n ?\n \" \" +\n CHAR.DOT +\n \" \" +\n description\n :\n \"\"\n )\n );\n}\n\n\n/* =========================================================\n PAGINATION\n========================================================= */\n\nfunction getCurrentViewData(){\n\n if(\n currentView ===\n \"search\"\n ){\n return currentSearchResults;\n }\n\n if(\n currentView ===\n \"student\"\n ){\n return currentStudentResults;\n }\n\n return adminSortedWorks;\n}\n\n\n/*\n TẤT CẢ CÁC VIEW:\n MOBILE = 10\n WEB = 24\n*/\nfunction getCurrentPageSize(){\n\n return getResponsivePageSize();\n}\n\n\nfunction clampCurrentPage(){\n\n const data =\n getCurrentViewData();\n\n const pageSize =\n getCurrentPageSize();\n\n const pages =\n Math.max(\n 1,\n Math.ceil(\n data.length /\n pageSize\n )\n );\n\n currentPage =\n Math.min(\n Math.max(\n currentPage,\n 1\n ),\n pages\n );\n\n return pages;\n}\n\n\nfunction getPageItems(\n data,\n pageSize\n){\n\n const pages =\n Math.max(\n 1,\n Math.ceil(\n data.length /\n pageSize\n )\n );\n\n currentPage =\n Math.min(\n Math.max(\n currentPage,\n 1\n ),\n pages\n );\n\n const start =\n (\n currentPage -\n 1\n )\n *\n pageSize;\n\n return{\n\n items:\n data.slice(\n start,\n start +\n pageSize\n ),\n\n start:start,\n\n pages:pages\n };\n}\n\n\n/* =========================================================\n RENDER\n========================================================= */\n\nfunction renderCards(\n data,\n numberStart\n){\n\n const grid =\n $(\"#tp18-grid\");\n\n grid.innerHTML =\n \"\";\n\n $(\"#tp18-loading\")\n .style.display =\n \"none\";\n\n $(\"#tp18-empty\")\n .style.display =\n data.length\n ?\n \"none\"\n :\n \"block\";\n\n if(\n currentView ===\n \"search\" &&\n !data.length\n ){\n\n setText(\n $(\"#tp18-empty\"),\n \"Không tìm thấy học viên hoặc bài nộp phù hợp.\"\n );\n\n }else{\n\n setText(\n $(\"#tp18-empty\"),\n \"Chưa có tác phẩm.\"\n );\n }\n\n const fragment =\n document.createDocumentFragment();\n\n data.forEach(\n function(\n item,\n index\n ){\n\n fragment.appendChild(\n createCard(\n\n item,\n\n Number(\n numberStart ||\n 1\n )\n +\n index\n )\n );\n }\n );\n\n grid.appendChild(\n fragment\n );\n}\n\n\n/* =========================================================\n PAGER\n========================================================= */\n\nfunction createPageButton(\n label,\n page,\n current,\n disabled\n){\n\n const button =\n document.createElement(\n \"button\"\n );\n\n button.type =\n \"button\";\n\n button.className =\n \"tp18-page\"\n +\n (\n current\n ?\n \" current\"\n :\n \"\"\n );\n\n setText(\n button,\n label\n );\n\n button.dataset.page =\n String(page);\n\n button.disabled =\n Boolean(disabled);\n\n return button;\n}\n\n\nfunction renderOnePager(element){\n\n element.innerHTML =\n \"\";\n\n if(!ADMIN_MODE){\n\n element.classList.remove(\n \"on\"\n );\n\n return;\n }\n\n const data =\n getCurrentViewData();\n\n const pageSize =\n getCurrentPageSize();\n\n const pages =\n Math.max(\n 1,\n Math.ceil(\n data.length /\n pageSize\n )\n );\n\n currentPage =\n Math.min(\n Math.max(\n currentPage,\n 1\n ),\n pages\n );\n\n if(\n !data.length\n ){\n\n element.classList.remove(\n \"on\"\n );\n\n return;\n }\n\n element.classList.add(\n \"on\"\n );\n\n const info =\n document.createElement(\n \"div\"\n );\n\n info.className =\n \"tp18-page-info\";\n\n const start =\n (\n currentPage -\n 1\n )\n *\n pageSize\n +\n 1;\n\n const end =\n Math.min(\n currentPage *\n pageSize,\n data.length\n );\n\n let infoText =\n \"Trang \" +\n currentPage +\n \"/\" +\n pages +\n \" \" +\n CHAR.DOT +\n \" \" +\n start +\n CHAR.DASH +\n end +\n \" / \" +\n data.length +\n \" bài\";\n\n if(\n currentView ===\n \"latest\"\n ){\n\n infoText +=\n \" \" +\n CHAR.DOT +\n \" \" +\n priorityCountCache +\n \" bài đang ưu tiên\";\n }\n\n if(\n pendingGrades.size\n ){\n\n infoText +=\n \" \" +\n CHAR.DOT +\n \" \" +\n UI.SYNC +\n \" \" +\n pendingGrades.size +\n \" bài đang đồng bộ\";\n }\n\n setText(\n info,\n infoText\n );\n\n element.appendChild(\n info\n );\n\n element.appendChild(\n createPageButton(\n\n CHAR.LEFT,\n\n currentPage - 1,\n\n false,\n\n currentPage === 1\n )\n );\n\n const visiblePages =\n [];\n\n for(\n let page=1;\n page<=pages;\n page++\n ){\n\n if(\n page === 1\n\n ||\n\n page === pages\n\n ||\n\n Math.abs(\n page -\n currentPage\n )\n <=\n 2\n ){\n\n visiblePages.push(\n page\n );\n }\n }\n\n let previous =\n 0;\n\n visiblePages.forEach(\n function(page){\n\n if(\n previous &&\n page -\n previous >\n 1\n ){\n\n const dots =\n document.createElement(\n \"span\"\n );\n\n setText(\n dots,\n CHAR.ELLIPSIS\n );\n\n element.appendChild(\n dots\n );\n }\n\n element.appendChild(\n createPageButton(\n\n String(page),\n\n page,\n\n page ===\n currentPage,\n\n false\n )\n );\n\n previous =\n page;\n }\n );\n\n element.appendChild(\n createPageButton(\n\n CHAR.RIGHT,\n\n currentPage + 1,\n\n false,\n\n currentPage ===\n pages\n )\n );\n}\n\n\nfunction renderPagers(){\n\n renderOnePager(\n $(\"#tp18-pages-top\")\n );\n\n renderOnePager(\n $(\"#tp18-pages-bottom\")\n );\n}\n\n\n/* =========================================================\n VIEWS\n========================================================= */\n\nfunction showLatest(){\n\n currentView =\n \"latest\";\n\n selectedStudentName =\n \"\";\n\n currentStudentResults =\n [];\n\n $(\"#tp18-student-bar\")\n .classList.remove(\n \"on\"\n );\n\n const pageSize =\n getCurrentPageSize();\n\n if(ADMIN_MODE){\n\n setText(\n $(\"#tp18-title\"),\n\n \"Tác phẩm học viên \" +\n CHAR.DOT +\n \" GVCN\"\n );\n\n setText(\n $(\"#tp18-subtitle\"),\n\n \"Chấm nhanh \" +\n CHAR.DOT +\n \" Đồng bộ an toàn \" +\n CHAR.DOT +\n \" Linh Thú \" +\n CHAR.DOT +\n \" Quà tặng \" +\n CHAR.DOT +\n \" Xoá bài\"\n );\n\n const page =\n getPageItems(\n adminSortedWorks,\n pageSize\n );\n\n renderCards(\n page.items,\n page.start + 1\n );\n\n }else{\n\n setText(\n $(\"#tp18-title\"),\n \"Tác phẩm học viên\"\n );\n\n const publicData =\n works\n .slice()\n .sort(\n newestSort\n )\n .slice(\n 0,\n pageSize\n );\n\n renderCards(\n publicData,\n 1\n );\n }\n\n renderPagers();\n\n updateSearchSummary();\n}\n\n\nfunction showSearchResults(){\n\n if(!ADMIN_MODE){\n return;\n }\n\n const query =\n clean(\n adminSearchQuery\n );\n\n if(!query){\n\n currentPage =\n 1;\n\n showLatest();\n\n return;\n }\n\n currentView =\n \"search\";\n\n selectedStudentName =\n \"\";\n\n currentStudentResults =\n [];\n\n $(\"#tp18-student-bar\")\n .classList.remove(\n \"on\"\n );\n\n const page =\n getPageItems(\n currentSearchResults,\n getCurrentPageSize()\n );\n\n setText(\n $(\"#tp18-title\"),\n \"Tìm học viên\"\n );\n\n setText(\n $(\"#tp18-subtitle\"),\n\n \"Kết quả theo tên hoặc mã học viên \" +\n CHAR.DOT +\n \" \" +\n currentSearchResults.length +\n \" bài\"\n );\n\n renderCards(\n page.items,\n page.start + 1\n );\n\n renderPagers();\n\n updateSearchSummary();\n}\n\n\nfunction buildCurrentStudentResults(\n name\n){\n\n const target =\n normalize(\n name\n );\n\n currentStudentResults =\n works\n .filter(\n item =>\n item.__nameNorm ===\n target\n )\n .slice()\n .sort(\n newestSort\n );\n}\n\n\nfunction showStudent(name){\n\n currentView =\n \"student\";\n\n selectedStudentName =\n name;\n\n buildCurrentStudentResults(\n name\n );\n\n const page =\n getPageItems(\n currentStudentResults,\n getCurrentPageSize()\n );\n\n setText(\n $(\"#tp18-title\"),\n\n \"Tác phẩm của \" +\n name\n );\n\n setText(\n $(\"#tp18-student-title\"),\n\n UI.BOOKS +\n \" \" +\n name +\n \" \" +\n CHAR.DOT +\n \" \" +\n currentStudentResults.length +\n \" tác phẩm\"\n );\n\n $(\"#tp18-student-bar\")\n .classList.add(\n \"on\"\n );\n\n renderCards(\n page.items,\n page.start + 1\n );\n\n renderPagers();\n}\n\n\nfunction restoreCurrentView(){\n\n if(\n currentView ===\n \"search\"\n\n &&\n\n clean(\n adminSearchQuery\n )\n ){\n\n showSearchResults();\n\n return;\n }\n\n if(\n currentView ===\n \"student\"\n\n &&\n\n selectedStudentName\n ){\n\n showStudent(\n selectedStudentName\n );\n\n return;\n }\n\n showLatest();\n}\n\n\n/* =========================================================\n STATUS\n========================================================= */\n\nfunction updateStatus(){\n\n const status =\n $(\"#tp18-status\");\n\n status.className =\n \"tp18-status on\"\n +\n (\n sourceName ===\n \"Form Responses 1\"\n ?\n \" warn\"\n :\n \"\"\n );\n\n if(ADMIN_MODE){\n\n const prefix =\n sourceName ===\n \"TacPhamWeb\"\n ?\n CHAR.CHECK +\n \" TacPhamWeb\"\n :\n UI.WARNING +\n \" Form Responses 1 dự phòng\";\n\n let message =\n prefix\n +\n \" \" +\n CHAR.DOT +\n \" \" +\n works.length +\n \" bài \" +\n CHAR.DOT +\n \" \" +\n priorityCountCache +\n \" bài đang cần ưu tiên\";\n\n if(\n deletedSubmissionIds.size\n ){\n\n message +=\n \" \" +\n CHAR.DOT +\n \" \" +\n UI.TRASH +\n \" \" +\n deletedSubmissionIds.size +\n \" bài đã xoá\";\n }\n\n if(\n pendingGrades.size\n ){\n\n message +=\n \" \" +\n CHAR.DOT +\n \" \" +\n UI.SYNC +\n \" \" +\n pendingGrades.size +\n \" bài đang đồng bộ\";\n }\n\n if(\n gradeSendQueue.length\n ){\n\n message +=\n \" \" +\n CHAR.DOT +\n \" \" +\n gradeSendQueue.length +\n \" bài trong hàng đợi gửi\";\n }\n\n setText(\n status,\n message\n );\n\n }else{\n\n setText(\n status,\n\n \"Đang hiển thị \" +\n Math.min(\n getCurrentPageSize(),\n works.length\n )\n +\n \" tác phẩm mới nhất.\"\n );\n }\n}\n\n\n/* =========================================================\n BUILD CACHE\n========================================================= */\n\nfunction buildAllRuntimeCaches(){\n\n prepareBaseWorkCache();\n\n buildLatestSubmissionMap();\n\n buildCatalogMaps();\n\n buildTeacherGiftStudentIndex();\n\n buildPriorityCache();\n\n buildSortedWorks();\n\n if(\n clean(\n adminSearchQuery\n )\n ){\n buildSearchResults();\n }\n\n if(\n selectedStudentName\n ){\n\n buildCurrentStudentResults(\n selectedStudentName\n );\n }\n}\n\n\n/* =========================================================\n LOAD DATA\n========================================================= */\n\nasync function loadData(){\n\n try{\n\n const workResult =\n await loadWorkData();\n\n const deleteText =\n await safeFetchCSV(\n URLS.deleteLog\n );\n\n if(deleteText){\n\n deleteLogData =\n csvObjects(\n deleteText\n );\n\n buildDeletedSubmissionIds(\n deleteLogData\n );\n }\n\n const syncedDuringRefresh =\n reconcilePendingGradesWithRawData(\n\n workResult.data,\n\n false\n );\n\n let freshWorks =\n applyPendingGradesToRawWorks(\n workResult.data\n );\n\n freshWorks =\n filterDeletedWorks(\n freshWorks\n );\n\n works =\n freshWorks;\n\n sourceName =\n workResult.source;\n\n if(\n syncedDuringRefresh >\n 0\n ){\n\n console.log(\n\n \"V18.5 xác nhận thêm\",\n\n syncedDuringRefresh,\n\n \"bài chấm.\"\n );\n }\n\n const results =\n await Promise.all([\n\n safeFetchCSV(\n URLS.giftCatalog\n ),\n\n safeFetchCSV(\n URLS.teacherGifts\n )\n ]);\n\n giftCatalog =\n results[0]\n ?\n csvObjects(\n results[0]\n )\n :\n [];\n\n teacherGiftData =\n results[1]\n ?\n csvObjects(\n results[1]\n )\n :\n [];\n\n /*\n Phải chuẩn bị __code/__submissionId trước khi\n tạo CSV bài nộp đã lọc XoaBai cho Reward Core.\n */\n prepareBaseWorkCache();\n\n buildCatalogMaps();\n\n await loadCanonicalAssetsForWorks(\n false\n );\n\n buildAllRuntimeCaches();\n\n updateStatus();\n\n updateSearchSummary();\n\n clampCurrentPage();\n\n restoreCurrentView();\n\n if(\n pendingGrades.size\n ){\n\n scheduleGradeSyncCheck(\n GRADE_SYNC_INTERVAL\n );\n }\n\n }catch(error){\n\n $(\"#tp18-loading\")\n .style.display =\n \"none\";\n\n $(\"#tp18-error\")\n .style.display =\n \"block\";\n\n setText(\n $(\"#tp18-error\"),\n\n \"Không thể tải dữ liệu: \" +\n (\n error.message ||\n error\n )\n );\n }\n}\n\n\n/* =========================================================\n INPUT\n========================================================= */\n\ndocument.addEventListener(\n \"input\",\n function(event){\n\n if(\n event.target.matches(\n \".tp18-score-input\"\n )\n ){\n\n updateAdminScoreColor(\n event.target\n );\n\n return;\n }\n\n if(\n ADMIN_MODE\n\n &&\n\n event.target.id ===\n \"tp18-search-input\"\n ){\n\n adminSearchQuery =\n clean(\n event.target.value\n );\n\n currentPage =\n 1;\n\n const clearButton =\n $(\"#tp18-search-clear\");\n\n if(clearButton){\n\n clearButton.classList.toggle(\n\n \"on\",\n\n Boolean(\n adminSearchQuery\n )\n );\n }\n\n if(searchTimer){\n\n clearTimeout(\n searchTimer\n );\n }\n\n if(\n !adminSearchQuery\n ){\n\n currentSearchResults =\n [];\n\n showLatest();\n\n return;\n }\n\n showSearchingState();\n\n searchTimer =\n setTimeout(\n function(){\n\n buildSearchResults();\n\n currentPage =\n 1;\n\n showSearchResults();\n\n searchTimer =\n null;\n\n },\n SEARCH_DEBOUNCE_MS\n );\n }\n }\n);\n\n\n/* =========================================================\n CHANGE\n========================================================= */\n\ndocument.addEventListener(\n \"change\",\n function(event){\n\n if(\n ADMIN_MODE\n\n &&\n\n event.target.matches(\n \".tp18-gift-select\"\n )\n ){\n\n updateGiftMeta(\n event.target\n );\n }\n }\n);\n\n\n/* =========================================================\n CLICK\n========================================================= */\n\ndocument.addEventListener(\n \"click\",\n function(event){\n\n /*\n ==================================================\n MỞ / ĐÓNG PHẦN GVCN TẶNG QUÀ\n ==================================================\n */\n\n const giftToggle =\n event.target.closest(\n \".tp18-admin-gift-toggle\"\n );\n\n if(giftToggle){\n\n event.preventDefault();\n\n event.stopPropagation();\n\n const giftBox =\n giftToggle.closest(\n \".tp18-admin-gift\"\n );\n\n if(!giftBox){\n return;\n }\n\n const isOpen =\n giftBox.classList.toggle(\n \"open\"\n );\n\n giftToggle.setAttribute(\n \"aria-expanded\",\n isOpen\n ?\n \"true\"\n :\n \"false\"\n );\n\n return;\n }\n\n\n /*\n XOÁ BÀI\n */\n\n const deleteButton =\n event.target.closest(\n \".tp18-delete\"\n );\n\n if(deleteButton){\n\n event.preventDefault();\n\n event.stopPropagation();\n\n handleDelete(\n deleteButton\n );\n\n return;\n }\n\n\n /*\n CLEAR SEARCH\n */\n\n if(\n event.target.closest(\n \"#tp18-search-clear\"\n )\n ){\n\n clearAdminSearch();\n\n return;\n }\n\n\n /*\n GIVE GIFT\n */\n\n const giftButton =\n event.target.closest(\n \".tp18-give\"\n );\n\n if(giftButton){\n\n handleGiveGift(\n giftButton\n );\n\n return;\n }\n\n\n /*\n GRADE\n */\n\n const gradeButton =\n event.target.closest(\n \".tp18-grade\"\n );\n\n if(\n gradeButton\n\n &&\n\n gradeButton.id !==\n \"tp18-back\"\n ){\n\n if(!ADMIN_MODE){\n return;\n }\n\n handleGrade(\n gradeButton\n );\n\n return;\n }\n\n\n /*\n PAGE\n */\n\n const pageButton =\n event.target.closest(\n \".tp18-page\"\n );\n\n if(\n ADMIN_MODE\n\n &&\n\n pageButton\n\n &&\n\n !pageButton.disabled\n ){\n\n currentPage =\n Number(\n pageButton.dataset.page\n );\n\n restoreCurrentView();\n\n const wrap =\n $(\"#tp18-wrap\");\n\n if(wrap){\n\n window.scrollTo({\n\n top:\n wrap\n .getBoundingClientRect()\n .top\n +\n window.scrollY\n -\n 15,\n\n behavior:\n \"smooth\"\n });\n }\n\n return;\n }\n\n\n /*\n BACK\n */\n\n if(\n event.target.closest(\n \"#tp18-back\"\n )\n ){\n\n if(\n ADMIN_MODE\n\n &&\n\n clean(\n adminSearchQuery\n )\n ){\n\n currentPage =\n 1;\n\n currentView =\n \"search\";\n\n showSearchResults();\n\n }else{\n\n currentPage =\n 1;\n\n showLatest();\n }\n\n return;\n }\n\n\n /*\n STUDENT\n */\n\n const studentName =\n event.target.closest(\n \".tp18-name\"\n );\n\n if(studentName){\n\n const card =\n studentName.closest(\n \".tp18-card\"\n );\n\n if(card){\n\n currentPage =\n 1;\n\n showStudent(\n card.dataset.name\n );\n }\n }\n }\n);\n\n\n/* =========================================================\n RESPONSIVE PAGINATION UPDATE\n========================================================= */\n\n/*\n Nếu người dùng xoay điện thoại hoặc thay đổi kích thước\n trình duyệt qua mốc 600px thì tự chuyển giữa:\n 10 bài <-> 24 bài.\n*/\n\nconst pageSizeMedia =\nwindow.matchMedia(\n \"(max-width:600px)\"\n);\n\nlet lastMobileState =\npageSizeMedia.matches;\n\n\nfunction handleResponsivePageChange(){\n\n const nextMobileState =\n pageSizeMedia.matches;\n\n if(\n nextMobileState ===\n lastMobileState\n ){\n return;\n }\n\n lastMobileState =\n nextMobileState;\n\n currentPage =\n 1;\n\n if(started){\n\n restoreCurrentView();\n\n updateStatus();\n }\n}\n\n\nif(\n typeof pageSizeMedia.addEventListener ===\n \"function\"\n){\n\n pageSizeMedia.addEventListener(\n \"change\",\n handleResponsivePageChange\n );\n\n}else if(\n typeof pageSizeMedia.addListener ===\n \"function\"\n){\n\n pageSizeMedia.addListener(\n handleResponsivePageChange\n );\n}\n\n\n\n/* =========================================================\n V18.5 - NHẬN SỰ KIỆN THAY ĐỔI PROFILE TỪ REWARD CORE\n========================================================= */\n\nlet canonicalRefreshTimer = null;\n\nwindow.addEventListener(\n \"ocdRewardProfileChanged\",\n function(event){\n\n const detail =\n event && event.detail\n ? event.detail\n : {};\n\n const code =\n clean(\n detail.studentCode ||\n detail.code ||\n detail.maHocVien ||\n \"\"\n );\n\n if(!code){\n return;\n }\n\n const exists =\n works.some(function(item){\n return normalize(item.__code) === normalize(code);\n });\n\n if(!exists){\n return;\n }\n\n if(canonicalRefreshTimer){\n clearTimeout(canonicalRefreshTimer);\n }\n\n canonicalRefreshTimer =\n setTimeout(\n async function(){\n\n canonicalRefreshTimer = null;\n\n try{\n\n await loadCanonicalStudentAssets(\n code,\n true\n );\n\n buildPriorityCache();\n buildSortedWorks();\n\n if(clean(adminSearchQuery)){\n buildSearchResults();\n }\n\n if(selectedStudentName){\n buildCurrentStudentResults(\n selectedStudentName\n );\n }\n\n restoreCurrentView();\n updateStatus();\n\n }catch(error){\n\n console.warn(\n \"Reward Core event refresh:\",\n error\n );\n }\n\n },\n 150\n );\n }\n);\n\n\n/* =========================================================\n START\n========================================================= */\n\nfunction startPage(){\n\n $(\"#tp18-wrap\")\n .classList.remove(\n \"locked\"\n );\n\n $(\"#tp18-admin-note\")\n .classList.toggle(\n \"on\",\n ADMIN_MODE\n );\n\n $(\"#tp18-submit\")\n .classList.toggle(\n \"hidden\",\n ADMIN_MODE\n );\n\n $(\"#tp18-admin-search\")\n .classList.toggle(\n \"on\",\n ADMIN_MODE\n );\n\n if(ADMIN_MODE){\n\n setText(\n $(\"#tp18-admin-note\"),\n\n UI.TOOL +\n \" Chế độ GVCN \" +\n CHAR.DOT +\n \" V18.5 Core Canonical Assets \" +\n CHAR.DOT +\n \" Quà tặng \" +\n CHAR.DOT +\n \" Xoá bài\"\n );\n\n setText(\n $(\"#tp18-search-icon\"),\n UI.SEARCH\n );\n\n setText(\n $(\"#tp18-search-clear\"),\n\n CHAR.CROSS +\n \" Xóa\"\n );\n }\n\n if(started){\n return;\n }\n\n started =\n true;\n\n loadData();\n\n setInterval(\n loadData,\n REFRESH_MS\n );\n}\n\n\n/* =========================================================\n ADMIN GATE\n========================================================= */\n\nfunction openGate(){\n\n if(!ADMIN_MODE){\n\n startPage();\n\n return;\n }\n\n const gate =\n document.createElement(\n \"div\"\n );\n\n gate.className =\n \"tp18-gate\";\n\n gate.innerHTML = `\n\n <div class=\"tp18-gate-box\">\n\n <div class=\"tp18-gate-icon\"></div>\n\n <h3>\n Khu vực GVCN\n </h3>\n\n <p>\n Nhập mã quản trị để chấm bài, tặng quà và quản lý bài nộp.\n </p>\n\n <input\n type=\"password\"\n inputmode=\"numeric\"\n autocomplete=\"off\"\n >\n\n <button type=\"button\">\n Mở trang quản trị\n </button>\n\n <div class=\"tp18-gate-error\"></div>\n\n </div>\n `;\n\n document.body.appendChild(\n gate\n );\n\n setText(\n gate.querySelector(\n \".tp18-gate-icon\"\n ),\n UI.LOCK\n );\n\n const input =\n gate.querySelector(\n \"input\"\n );\n\n const error =\n gate.querySelector(\n \".tp18-gate-error\"\n );\n\n const verify =\n function(){\n\n if(\n input.value.trim()\n !==\n ADMIN_ACCESS_CODE\n ){\n\n setText(\n error,\n \"Mã quản trị không đúng.\"\n );\n\n input.value =\n \"\";\n\n input.focus();\n\n return;\n }\n\n gate.remove();\n\n startPage();\n };\n\n gate.querySelector(\n \"button\"\n )\n .addEventListener(\n \"click\",\n verify\n );\n\n input.addEventListener(\n \"keydown\",\n function(event){\n\n if(\n event.key ===\n \"Enter\"\n ){\n verify();\n }\n }\n );\n\n input.focus();\n}\n\n\n/* =========================================================\n INIT\n========================================================= */\n\nopenGate();\n\n})();\n\n";
(function(){
"use strict";
/* =========================================================
CHỐNG KHỞI ĐỘNG LẶP
========================================================= */
if(window.TP18_V1851F_STARTED){
return;
}
window.TP18_V1851F_STARTED = true;
/* =========================================================
CONFIG
========================================================= */
const NOP_BAI_ID =
"1GJoTRsbq0kZfZrDdh0uCC667PwS3Bgkje2fHQnwnCKs";
/*
FORM RESPONSES 1
Tab chứa:
- Dấu thời gian
- Họ và tên
- Mã học viên
- Tổ
- Khóa
- Tải bài tập lên
- Mô tả bài tập
*/
const FORM_RESPONSES_GID =
"1837470623";
const DESCRIPTION_URL =
"https://docs.google.com/spreadsheets/d/"
+
NOP_BAI_ID
+
"/export?format=csv&gid="
+
FORM_RESPONSES_GID;
/* =========================================================
CORE v18.5.1
Đây là bản Core mới:
- Reward Core Canonical Assets
- Không tự replay tài sản tại trang Admin
- Dùng StudentRewardSystem chung
========================================================= */
const CORE_URL =
"https://cdn.jsdelivr.net/gh/"
+
"vnthudao-source/ocd4-assets"
+
"@a6ad656fe020956371cfc96609e6708e2736e949/"
+
"student-work.js";
/* =========================================================
STATE
========================================================= */
const descriptionIndex = {
byDriveId:
new Map(),
byUrl:
new Map(),
byStudentTime:
new Map()
};
/* =========================================================
CLEAN
========================================================= */
function clean(value){
return String(
value == null
?
""
:
value
)
.replace(
/^\uFEFF/,
""
)
.trim();
}
/* =========================================================
NORMALIZE
========================================================= */
function normalizeLocal(value){
return clean(value)
.toLowerCase()
.normalize("NFD")
.replace(
/[\u0300-\u036f]/g,
""
)
.replace(
/đ/g,
"d"
)
.replace(
/\s+/g,
" "
)
.trim();
}
/* =========================================================
CSV PARSER
========================================================= */
function parseCSV(text){
const rows = [];
let row = [];
let value = "";
let quoted = false;
text =
String(text || "")
.replace(
/^\uFEFF/,
""
);
for(
let i = 0;
i < text.length;
i++
){
const char =
text[i];
const next =
text[i + 1];
/* =================================================
DOUBLE QUOTE TRONG CELL
================================================= */
if(
char === '"' &&
quoted &&
next === '"'
){
value += '"';
i++;
continue;
}
/* =================================================
MỞ / ĐÓNG QUOTE
================================================= */
if(
char === '"'
){
quoted =
!quoted;
continue;
}
/* =================================================
NGĂN CỘT
================================================= */
if(
char === "," &&
!quoted
){
row.push(
value
);
value = "";
continue;
}
/* =================================================
NGĂN DÒNG
================================================= */
if(
(
char === "\n" ||
char === "\r"
)
&&
!quoted
){
if(
char === "\r" &&
next === "\n"
){
i++;
}
row.push(
value
);
value = "";
if(
row.some(
function(cell){
return(
clean(cell) !==
""
);
}
)
){
rows.push(
row
);
}
row = [];
continue;
}
value += char;
}
/* =====================================================
DÒNG CUỐI
====================================================== */
if(
value ||
row.length
){
row.push(
value
);
if(
row.some(
function(cell){
return(
clean(cell) !==
""
);
}
)
){
rows.push(
row
);
}
}
return rows;
}
/* =========================================================
URL ĐẦU TIÊN TRONG CELL
========================================================= */
function firstUrl(value){
const text =
clean(value);
if(!text){
return "";
}
const match =
text.match(
/https?:\/\/[^\s,]+/i
);
return(
match
?
match[0]
:
text
);
}
/* =========================================================
GOOGLE DRIVE FILE ID
========================================================= */
function getDriveId(value){
const url =
firstUrl(
value
);
if(!url){
return "";
}
const patterns = [
/\/file\/d\/([^/?&#]+)/i,
/\/d\/([^/?&#]+)/i,
/[?&]id=([^&#]+)/i
];
for(
let i = 0;
i < patterns.length;
i++
){
const match =
url.match(
patterns[i]
);
if(
match &&
match[1]
){
return clean(
match[1]
);
}
}
return "";
}
/* =========================================================
NORMALIZE URL
========================================================= */
function normalizeUrl(value){
return firstUrl(
value
)
.replace(
/&/gi,
"&"
)
.replace(
/\/+$/,
""
)
.trim();
}
/* =========================================================
NORMALIZE TIME
========================================================= */
function normalizeTime(value){
return clean(value)
.replace(
/\s+/g,
" "
);
}
/* =========================================================
STUDENT + TIME KEY
========================================================= */
function studentTimeKey(
code,
time
){
return(
normalizeLocal(code)
+
"||"
+
normalizeTime(time)
);
}
/* =========================================================
TÌM CỘT HEADER
========================================================= */
function findHeaderIndex(
header,
keyword,
fallback
){
const normalizedKeyword =
normalizeLocal(
keyword
);
for(
let i = 0;
i < header.length;
i++
){
if(
normalizeLocal(
header[i]
)
.includes(
normalizedKeyword
)
){
return i;
}
}
return fallback;
}
/* =========================================================
BUILD DESCRIPTION INDEX
========================================================= */
function buildDescriptionIndex(csvText){
const rows =
parseCSV(
csvText
);
if(
!rows ||
rows.length < 2
){
throw new Error(
"Form Responses 1 không có dữ liệu."
);
}
const header =
rows[0];
/*
A = 0 : Dấu thời gian
C = 2 : Mã học viên
F = 5 : Tải bài tập lên
G = 6 : Mô tả bài tập
Tìm theo header trước,
fallback theo vị trí cột.
*/
const timeColumn =
findHeaderIndex(
header,
"Dấu thời gian",
0
);
const codeColumn =
findHeaderIndex(
header,
"Mã học viên",
2
);
const workColumn =
findHeaderIndex(
header,
"Tải bài tập lên",
5
);
const descriptionColumn =
findHeaderIndex(
header,
"Mô tả bài tập",
6
);
if(
descriptionColumn < 0
){
throw new Error(
"Không tìm thấy cột Mô tả bài tập."
);
}
descriptionIndex
.byDriveId
.clear();
descriptionIndex
.byUrl
.clear();
descriptionIndex
.byStudentTime
.clear();
/* =====================================================
DUYỆT TỪ DÒNG 2
====================================================== */
for(
let r = 1;
r < rows.length;
r++
){
const row =
rows[r];
const submittedAt =
clean(
row[timeColumn]
);
const studentCode =
clean(
row[codeColumn]
);
const workUrl =
clean(
row[workColumn]
);
const description =
clean(
row[descriptionColumn]
);
/*
Không có mô tả
-> không tạo label.
*/
if(!description){
continue;
}
/* =================================================
1. DRIVE FILE ID
================================================= */
const driveId =
getDriveId(
workUrl
);
if(driveId){
descriptionIndex
.byDriveId
.set(
driveId,
description
);
}
/* =================================================
2. URL
================================================= */
const normalizedWorkUrl =
normalizeUrl(
workUrl
);
if(normalizedWorkUrl){
descriptionIndex
.byUrl
.set(
normalizedWorkUrl,
description
);
}
/* =================================================
3. STUDENT + TIME
================================================= */
if(
studentCode &&
submittedAt
){
descriptionIndex
.byStudentTime
.set(
studentTimeKey(
studentCode,
submittedAt
),
description
);
}
}
}
/* =========================================================
LẤY MÔ TẢ CỦA MỘT BÀI
========================================================= */
function getDescription(
workUrl,
studentCode,
submittedAt
){
/* =====================================================
ƯU TIÊN 1:
DRIVE FILE ID
====================================================== */
const driveId =
getDriveId(
workUrl
);
if(
driveId &&
descriptionIndex
.byDriveId
.has(
driveId
)
){
return(
descriptionIndex
.byDriveId
.get(
driveId
)
||
""
);
}
/* =====================================================
ƯU TIÊN 2:
URL
====================================================== */
const normalizedWork =
normalizeUrl(
workUrl
);
if(
normalizedWork &&
descriptionIndex
.byUrl
.has(
normalizedWork
)
){
return(
descriptionIndex
.byUrl
.get(
normalizedWork
)
||
""
);
}
/* =====================================================
ƯU TIÊN 3:
MÃ HỌC VIÊN + THỜI GIAN
====================================================== */
if(
studentCode &&
submittedAt
){
const key =
studentTimeKey(
studentCode,
submittedAt
);
if(
descriptionIndex
.byStudentTime
.has(
key
)
){
return(
descriptionIndex
.byStudentTime
.get(
key
)
||
""
);
}
}
return "";
}
/* =========================================================
BRIDGE MÔ TẢ CHO STUDENT-WORK CORE
========================================================= */
window.TP18_WORK_DESCRIPTION = {
get:
function(
workUrl,
studentCode,
submittedAt
){
return getDescription(
workUrl,
studentCode,
submittedAt
);
},
count:
function(){
return(
descriptionIndex
.byDriveId
.size
);
}
};
/* =========================================================
FETCH
========================================================= */
async function fetchText(
url,
noCache
){
let finalUrl =
url;
if(noCache){
finalUrl +=
(
url.includes("?")
?
"&"
:
"?"
)
+
"_="
+
Date.now();
}
const response =
await fetch(
finalUrl,
{
cache:
noCache
?
"no-store"
:
"default"
}
);
if(!response.ok){
throw new Error(
"HTTP "
+
response.status
+
": "
+
url
);
}
return await response.text();
}
/* =========================================================
REPLACE BẮT BUỘC
========================================================= */
function replaceRequired(
source,
regex,
replacement,
name
){
regex.lastIndex =
0;
if(
!regex.test(
source
)
){
throw new Error(
"Không tìm thấy vị trí nâng cấp: "
+
name
);
}
regex.lastIndex =
0;
return source.replace(
regex,
replacement
);
}
/* =========================================================
PATCH CORE v18.5.1
THÊM MODULE MÔ TẢ BÀI TẬP
LƯU Ý:
Không patch Reward Core.
Không patch Linh Thạch.
Không patch tài sản.
Không patch quyền sở hữu Linh Thú.
Những phần đó do student-work.js v18.5.1
+ StudentRewardSystem xử lý.
========================================================= */
function patchCore(source){
let code =
String(
source || ""
);
/* =====================================================
PATCH 1
THÊM DESCRIPTION VÀO ITEM
====================================================== */
code =
replaceRequired(
code,
/item\.__workUrl\s*=\s*originalUrl\(\s*item\.__rawWork\s*\);/,
[
"item.__workUrl =",
"originalUrl(",
" item.__rawWork",
");",
"",
"item.__exerciseDescription =",
"(",
" window.TP18_WORK_DESCRIPTION",
" &&",
" typeof window.TP18_WORK_DESCRIPTION.get === \"function\"",
")",
"?",
"window.TP18_WORK_DESCRIPTION.get(",
" item.__rawWork,",
" item.__code,",
" item.__timestampText",
")",
":",
"\"\";",
"",
"item.__exerciseDescriptionNorm =",
"normalize(",
" item.__exerciseDescription",
");"
]
.join(
"\n"
),
"Mô tả bài tập"
);
/* =====================================================
PATCH 2
SEARCH:
TÊN + MÃ + MÔ TẢ
====================================================== */
code =
replaceRequired(
code,
/item\.__nameNorm\s*\.includes\(\s*query\s*\)\s*\|\|\s*item\.__codeNorm\s*\.includes\(\s*query\s*\)/,
[
"item.__nameNorm",
".includes(query)",
"",
"||",
"",
"item.__codeNorm",
".includes(query)",
"",
"||",
"",
"(",
" item.__exerciseDescriptionNorm",
" ||",
" \"\"",
")",
".includes(query)"
]
.join(
"\n"
),
"Bộ lọc mô tả"
);
/* =====================================================
PATCH 3
HIỂN THỊ DESCRIPTION SAU TÊN
Không dùng HTML entity cho icon.
====================================================== */
code =
replaceRequired(
code,
/(<div class="tp18-name">\s*\$\{escapeHTML\(\s*name\s*\|\|\s*"Học viên"\s*\)\}\s*<\/div>)(\s*<div class="tp18-meta">)/,
function(
full,
nameBlock,
metaBlock
){
return(
nameBlock
+
"\n\n"
+
" ${\n"
+
" item.__exerciseDescription\n"
+
" ?\n"
+
" `\n"
+
" <div class=\"tp18-work-description\">\n"
+
" <span class=\"tp18-work-description-icon\">${escapeHTML(String.fromCodePoint(0x270E))}</span>\n"
+
" <span class=\"tp18-work-description-text\">${escapeHTML(item.__exerciseDescription)}</span>\n"
+
" </div>\n"
+
" `\n"
+
" :\n"
+
" \"\"\n"
+
" }\n\n"
+
metaBlock
);
},
"Hiển thị mô tả"
);
/* =====================================================
PATCH 4
SUBTITLE KẾT QUẢ SEARCH
====================================================== */
code =
code.replace(
/Kết quả theo tên hoặc mã học viên/g,
"Kết quả theo tên, mã học viên hoặc mô tả bài tập"
);
return code;
}
/* =========================================================
CHẠY CORE
========================================================= */
function executeCore(
source
){
const script =
document.createElement(
"script"
);
script.type =
"text/javascript";
script.text =
source
+
"\n//# sourceURL=student-work-v18.5.1F.js";
document.body.appendChild(
script
);
}
/* =========================================================
FALLBACK
Nếu module Mô tả bài tập gặp lỗi,
vẫn tải trực tiếp student-work.js v18.5.1.
QUAN TRỌNG:
Fallback KHÔNG quay về v18.4D.
Như vậy trang không bao giờ quay lại
công thức tài sản cũ.
========================================================= */
async function loadOriginalCoreOnly(){
const core =
await Promise.resolve( window.__TP18_CORE_SOURCE );
executeCore(
core
);
}
/* =========================================================
START
========================================================= */
async function start(){
try{
/*
TẢI SONG SONG:
1. CSV mô tả
2. student-work.js v18.5.1
*/
const results =
await Promise.all([
fetchText(
DESCRIPTION_URL,
true
),
Promise.resolve( window.__TP18_CORE_SOURCE )
]);
const descriptionCSV =
results[0];
const originalCore =
results[1];
/* =================================================
INDEX MÔ TẢ
================================================= */
buildDescriptionIndex(
descriptionCSV
);
/* =================================================
PATCH MODULE MÔ TẢ
================================================= */
const upgradedCore =
patchCore(
originalCore
);
/* =================================================
EXECUTE
================================================= */
executeCore(
upgradedCore
);
console.log(
"STUDENT WORK v18.5.1F đã khởi động."
);
console.log(
"Core:",
"student-work.js @ v18.5.1"
);
console.log(
"Số bài có mô tả:",
window.TP18_WORK_DESCRIPTION.count()
);
}catch(error){
console.error(
"STUDENT WORK v18.5.1F:",
error
);
/*
Module mô tả lỗi:
vẫn giữ trang Admin bằng Core v18.5.1.
Tuyệt đối không fallback v18.4D.
*/
try{
await loadOriginalCoreOnly();
console.warn(
"Module mô tả gặp lỗi. Đã chạy trực tiếp Core v18.5.1."
);
}catch(fallbackError){
console.error(
"Không thể tải Core v18.5.1:",
fallbackError
);
const loading =
document.getElementById(
"tp18-loading"
);
const errorBox =
document.getElementById(
"tp18-error"
);
if(loading){
loading.style.display =
"none";
}
if(errorBox){
errorBox.style.display =
"block";
errorBox.textContent =
"Không thể tải trang chấm bài.";
}
}
}
}
/* =========================================================
DOM READY
========================================================= */
if(
document.readyState ===
"loading"
){
document.addEventListener(
"DOMContentLoaded",
start,
{
once:true
}
);
}else{
start();
}
})();
Nhận xét
Đăng nhận xét