student-work/core-v31.kho.js · Gộp student-work.css v18.5.1 + student-work.js a6ad656 + mã khởi động trang core-v31

/* 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        /&nbsp;/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,\"&amp;\")\n    .replace(/</g,\"&lt;\")\n    .replace(/>/g,\"&gt;\")\n    .replace(/\"/g,\"&quot;\")\n    .replace(/'/g,\"&#39;\");\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(
        /&amp;/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

Bài đăng phổ biến từ blog này

trang/thudao/dang-ky-hoc.html · Bản trước khi chuyển sang kho (tải từ GitHub/jsDelivr)

trang/thudao/bai-viet/benh-cua-thu-dan-duong-dai-the-trien-lam-xu-thu-va-nguy-menh-de-vuot-co-nhan.html · Bản trước khi chuyển sang kho (có video, tải từ GitHub/jsDelivr)

trang/thudao/tai-lieu.html · Bản đang chạy 11/11/2026 (khối tải nhanh từ kho)