feat: live search, student detail card, score tier color coding

- SearchForm: 300ms debounced live search, visible label, aria-live mode
  hint, inline clear (×) button, minimum-length guards (3 digits / 2 chars)
- StudentDetail: rich single-result card with copy-SBD action, subject
  tiles with 5-tier color+icon coding, admission-block totals (A/A1/B/C/
  D1-D4) computed only when the student has all 3 required subject scores
- ScoreTable: per-cell tier color coding with tooltip
- App.jsx: switches to detail card when exactly 1 result; onClear wired
- App.css: semantic token system with light + dark (prefers-color-scheme)
  variants, :focus-visible ring, reduced-motion fallback, desktop-first
  breakpoints; removes hardcoded hex across components
- admission-blocks.js: pure data + calculator, no UI coupling
This commit is contained in:
tiennm99 committed 2026-04-14 21:21:46 +07:00
1 parent d968459b9d
commit c832810b78
6 files changed
+707 -98

No files matched your search

+434 -71
View File
@@ -1,8 +1,78 @@
/* Design tokens — semantic, paired for light + dark */
:root {
--bg: #f7f7fb;
--surface: #ffffff;
--surface-alt: #f0f0f5;
--border: #e2e2eb;
--border-strong: #d0d0dc;
--text: #1a1a2e;
--text-muted: #5c6072;
--text-subtle: #868a99;
--primary: #3b4fe4;
--primary-hover: #2d3ec9;
--primary-ink: #ffffff;
--focus-ring: 0 0 0 3px rgba(59, 79, 228, 0.35);
--radius: 10px;
--radius-sm: 6px;
--shadow-sm: 0 1px 2px rgba(15, 20, 40, 0.05);
--shadow-md: 0 6px 20px rgba(15, 20, 40, 0.08);
/* Score tier colors (AA-contrast against tier-tinted surfaces) */
--score-poor-bg: #fdecea; --score-poor-ink: #a51f1f; --score-poor-accent: #d33030;
--score-weak-bg: #fff4e0; --score-weak-ink: #8a5500; --score-weak-accent: #e08a00;
--score-fair-bg: #e8f0fe; --score-fair-ink: #1f4abf; --score-fair-accent: #3b73e8;
--score-good-bg: #e6f6ec; --score-good-ink: #196a36; --score-good-accent: #2e9e55;
--score-excellent-bg: #fff6d6; --score-excellent-ink: #7a5100; --score-excellent-accent: #d4a015;
--error-bg: #fdecea;
--error-ink: #a51f1f;
--warning-bg: #fff3e0;
--warning-ink: #8a4a00;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1220;
--surface: #171a2b;
--surface-alt: #1e2236;
--border: #2a2e44;
--border-strong: #3a3f58;
--text: #eaeaf0;
--text-muted: #b0b4c7;
--text-subtle: #8a8fa4;
--primary: #6d82ff;
--primary-hover: #879aff;
--primary-ink: #0f1220;
--focus-ring: 0 0 0 3px rgba(109, 130, 255, 0.45);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.55);
--score-poor-bg: #3a1717; --score-poor-ink: #ff9b9b; --score-poor-accent: #ff6b6b;
--score-weak-bg: #3a2a0d; --score-weak-ink: #ffc980; --score-weak-accent: #ffb347;
--score-fair-bg: #14244a; --score-fair-ink: #a9c0ff; --score-fair-accent: #7a96ff;
--score-good-bg: #0f2e1b; --score-good-ink: #9ee3b5; --score-good-accent: #55c47a;
--score-excellent-bg: #332a0a; --score-excellent-ink: #ffd76a; --score-excellent-accent: #f0bd3b;
--error-bg: #3a1717; --error-ink: #ff9b9b;
--warning-bg: #3a2a0d; --warning-ink: #ffc980;
}
}
html { background: var(--bg); color: var(--text); }
body { margin: 0; background: var(--bg); color: var(--text); }
*:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: var(--radius-sm);
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
font-family: system-ui, -apple-system, sans-serif;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--text);
}
header {
@@ -12,12 +82,12 @@ header {
header h1 {
font-size: 1.8rem;
color: #1a1a2e;
color: var(--text);
margin-bottom: 0.25rem;
}
.subtitle {
color: #666;
color: var(--text-muted);
font-size: 0.95rem;
}
@@ -27,7 +97,7 @@ header h1 {
gap: 0;
max-width: 600px;
margin: 0 auto 1.5rem;
border-bottom: 2px solid #ddd;
border-bottom: 2px solid var(--border);
}
.tab {
@@ -39,17 +109,17 @@ header h1 {
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
color: #666;
color: var(--text-muted);
transition: color 0.2s, border-color 0.2s;
}
.tab:hover {
color: #4361ee;
color: var(--primary);
}
.tab.active {
color: #4361ee;
border-bottom-color: #4361ee;
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: 600;
}
@@ -69,23 +139,24 @@ header h1 {
.preset-label {
font-size: 0.85rem;
color: #666;
color: var(--text-muted);
white-space: nowrap;
}
.preset-btn {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
background: #f0f0f5;
border: 1px solid #ddd;
background: var(--surface-alt);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.preset-btn:hover:not(:disabled) {
background: #e8e8f0;
border-color: #4361ee;
background: var(--border);
border-color: var(--primary);
}
.preset-btn:disabled {
@@ -102,7 +173,9 @@ header h1 {
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
border: 2px solid #ddd;
background: var(--surface);
color: var(--text);
border: 2px solid var(--border);
border-radius: 8px;
outline: none;
resize: vertical;
@@ -112,7 +185,7 @@ header h1 {
}
.query-form textarea:focus {
border-color: #4361ee;
border-color: var(--primary);
}
.query-actions {
@@ -125,8 +198,8 @@ header h1 {
.query-actions button {
padding: 0.6rem 1.5rem;
font-size: 0.95rem;
background: #4361ee;
color: white;
background: var(--primary);
color: var(--primary-ink);
border: none;
border-radius: 8px;
cursor: pointer;
@@ -134,7 +207,7 @@ header h1 {
}
.query-actions button:hover:not(:disabled) {
background: #3a56d4;
background: var(--primary-hover);
}
.query-actions button:disabled {
@@ -144,62 +217,122 @@ header h1 {
.exec-time {
font-size: 0.85rem;
color: #666;
color: var(--text-muted);
}
/* Search form */
.search-form {
max-width: 640px;
margin: 0 auto 1.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.search-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.search-input-row {
display: flex;
gap: 0.5rem;
max-width: 600px;
margin: 0 auto 1.5rem;
}
.search-form input {
.search-input-wrap {
position: relative;
flex: 1;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
}
.search-input-wrap input {
flex: 1;
width: 100%;
padding: 0.75rem 2.25rem 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #ddd;
border-radius: 8px;
background: var(--surface);
color: var(--text);
border: 2px solid var(--border);
border-radius: var(--radius);
outline: none;
transition: border-color 0.2s;
transition: border-color 0.15s;
}
.search-form input:focus {
border-color: #4361ee;
.search-input-wrap input::-webkit-search-cancel-button { display: none; }
.search-input-wrap input:focus {
border-color: var(--primary);
}
.search-form button {
.clear-btn {
position: absolute;
right: 0.4rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 50%;
color: var(--text-muted);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.clear-btn:hover {
background: var(--surface-alt);
color: var(--text);
}
.primary-btn,
.search-form button[type="submit"] {
padding: 0.75rem 1.5rem;
font-size: 1rem;
background: #4361ee;
color: white;
font-weight: 600;
background: var(--primary);
color: var(--primary-ink);
border: none;
border-radius: 8px;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.2s;
transition: background 0.15s, transform 0.1s;
min-height: 44px;
}
.search-form button:hover:not(:disabled) {
background: #3a56d4;
.primary-btn:hover:not(:disabled) { background: var(--primary-hover); }
.primary-btn:active:not(:disabled) { transform: scale(0.98); }
.primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.search-hint {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
min-height: 1.2em;
transition: color 0.15s;
}
.search-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
.search-hint.mode-sbd-short,
.search-hint.mode-name-short {
color: var(--score-weak-accent);
}
/* Loading */
.loading {
text-align: center;
margin: 1rem 0;
color: #555;
color: var(--text-muted);
}
.progress-bar {
width: 300px;
height: 8px;
background: #eee;
background: var(--surface-alt);
border-radius: 4px;
margin: 0.5rem auto;
overflow: hidden;
@@ -207,7 +340,7 @@ header h1 {
.progress-fill {
height: 100%;
background: #4361ee;
background: var(--primary);
border-radius: 4px;
transition: width 0.3s;
}
@@ -219,7 +352,7 @@ header h1 {
}
.result-count {
color: #555;
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
@@ -228,36 +361,43 @@ table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
background: var(--surface);
}
thead th {
background: #f0f0f5;
background: var(--surface-alt);
color: var(--text);
padding: 0.6rem 0.5rem;
text-align: left;
white-space: nowrap;
position: sticky;
top: 0;
border-bottom: 2px solid #ddd;
border-bottom: 2px solid var(--border-strong);
}
tbody td {
padding: 0.5rem;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--border);
color: var(--text);
}
tbody tr:hover {
background: #f8f9ff;
}
tbody tr:hover { background: var(--surface-alt); }
.name-cell {
white-space: nowrap;
}
.name-cell { white-space: nowrap; }
.score-cell {
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 500;
}
/* Score tier color coding — used on both table cells and detail tiles */
.score-cell.tier-poor { color: var(--score-poor-ink); }
.score-cell.tier-weak { color: var(--score-weak-ink); }
.score-cell.tier-fair { color: var(--score-fair-ink); }
.score-cell.tier-good { color: var(--score-good-ink); font-weight: 600; }
.score-cell.tier-excellent { color: var(--score-excellent-ink); font-weight: 700; }
/* Messages */
.no-results,
.error,
@@ -269,18 +409,18 @@ tbody tr:hover {
}
.no-results {
color: #666;
background: #f5f5f5;
color: var(--text-muted);
background: var(--surface-alt);
}
.error {
color: #d32f2f;
background: #fdecea;
color: var(--error-ink);
background: var(--error-bg);
}
.warning {
color: #e65100;
background: #fff3e0;
color: var(--warning-ink);
background: var(--warning-bg);
font-size: 0.9rem;
}
@@ -289,27 +429,250 @@ footer {
text-align: center;
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid #eee;
color: #999;
border-top: 1px solid var(--border);
color: var(--text-subtle);
font-size: 0.85rem;
}
/* ---- Student detail card ---- */
.detail-card {
max-width: 900px;
margin: 1.5rem auto 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: calc(var(--radius) + 4px);
box-shadow: var(--shadow-md);
padding: 1.75rem;
}
.detail-header {
padding-bottom: 1rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--border);
}
.detail-identity h2 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
color: var(--text);
letter-spacing: 0.01em;
}
.detail-meta {
display: flex;
gap: 2rem;
margin: 0;
flex-wrap: wrap;
}
.detail-meta > div {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.detail-meta dt {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-subtle);
}
.detail-meta dd {
margin: 0;
font-size: 1rem;
color: var(--text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.mono {
font-variant-numeric: tabular-nums;
font-family: ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", monospace;
}
.copy-btn {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
background: var(--surface-alt);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.copy-btn:hover {
background: var(--primary);
color: var(--primary-ink);
border-color: var(--primary);
}
.section-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
margin: 0 0 0.75rem;
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.section-hint {
font-size: 0.8rem;
font-weight: 400;
color: var(--text-subtle);
}
/* Subject tile grid */
.score-grid {
list-style: none;
margin: 0 0 1.5rem;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.6rem;
}
.score-tile {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.75rem 0.9rem;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface-alt);
transition: transform 0.1s;
}
.score-tile .score-subject {
font-size: 0.8rem;
color: var(--text-muted);
letter-spacing: 0.02em;
}
.score-tile .score-value {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.1;
color: var(--text);
}
.score-tile .score-tier {
font-size: 0.78rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.3rem;
}
.score-tile .score-symbol {
font-weight: 700;
font-size: 0.9rem;
}
.score-tile.tier-poor { background: var(--score-poor-bg); border-color: var(--score-poor-accent); }
.score-tile.tier-poor .score-value,
.score-tile.tier-poor .score-tier,
.score-tile.tier-poor .score-subject { color: var(--score-poor-ink); }
.score-tile.tier-weak { background: var(--score-weak-bg); border-color: var(--score-weak-accent); }
.score-tile.tier-weak .score-value,
.score-tile.tier-weak .score-tier,
.score-tile.tier-weak .score-subject { color: var(--score-weak-ink); }
.score-tile.tier-fair { background: var(--score-fair-bg); border-color: var(--score-fair-accent); }
.score-tile.tier-fair .score-value,
.score-tile.tier-fair .score-tier,
.score-tile.tier-fair .score-subject { color: var(--score-fair-ink); }
.score-tile.tier-good { background: var(--score-good-bg); border-color: var(--score-good-accent); }
.score-tile.tier-good .score-value,
.score-tile.tier-good .score-tier,
.score-tile.tier-good .score-subject { color: var(--score-good-ink); }
.score-tile.tier-excellent { background: var(--score-excellent-bg); border-color: var(--score-excellent-accent); }
.score-tile.tier-excellent .score-value,
.score-tile.tier-excellent .score-tier,
.score-tile.tier-excellent .score-subject { color: var(--score-excellent-ink); }
/* Admission block rows */
.block-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.block-row {
display: grid;
grid-template-columns: 44px 1fr auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.9rem;
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.block-code {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
padding: 0.15rem 0;
background: var(--primary);
color: var(--primary-ink);
border-radius: 6px;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.02em;
}
.block-label {
color: var(--text-muted);
font-size: 0.9rem;
}
.block-parts {
color: var(--text-subtle);
font-size: 0.85rem;
}
.block-total {
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
min-width: 3.5rem;
text-align: right;
}
/* Responsive */
@media (max-width: 600px) {
header h1 {
font-size: 1.3rem;
header h1 { font-size: 1.3rem; }
.search-input-row { flex-direction: column; }
.search-input-row .primary-btn { width: 100%; }
table { font-size: 0.8rem; }
.preset-list { flex-direction: column; align-items: flex-start; }
.detail-card { padding: 1.1rem; }
.detail-identity h2 { font-size: 1.25rem; }
.block-row {
grid-template-columns: 36px 1fr auto;
gap: 0.5rem;
}
.search-form {
flex-direction: column;
}
table {
font-size: 0.8rem;
.block-parts { display: none; }
.score-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
}
}
.preset-list {
flex-direction: column;
align-items: flex-start;
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+11 -2
View File
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from "react";
import { useSqlite } from "./hooks/use-sqlite";
import { SearchForm } from "./components/search-form";
import { ScoreTable } from "./components/score-table";
import { StudentDetail } from "./components/student-detail";
import { CustomQuery } from "./components/custom-query";
import "./App.css";
@@ -129,13 +130,21 @@ function App() {
{activeTab === "search" && (
<>
<SearchForm onSearch={handleSearch} disabled={loading || !!error} />
<SearchForm
onSearch={handleSearch}
onClear={() => setResults(null)}
disabled={loading || !!error}
/>
{searchError && (
<p className="error">Lỗi truy vấn: {searchError}</p>
)}
<ScoreTable results={results} />
{results && results.length === 1 ? (
<StudentDetail student={results[0]} />
) : (
<ScoreTable results={results} />
)}
{results && results.length >= MAX_RESULTS && (
<p className="warning">
+15 -5
View File
@@ -1,3 +1,5 @@
import { scoreTier } from "../lib/admission-blocks";
const SUBJECT_COLUMNS = [
{ key: "toan", label: "Toán" },
{ key: "ngu_van", label: "Ngữ văn" },
@@ -51,11 +53,19 @@ export function ScoreTable({ results }) {
<td>{row.so_bao_danh}</td>
<td className="name-cell">{row.ho_ten}</td>
<td>{row.ngay_sinh || "—"}</td>
{visibleColumns.map((col) => (
<td key={col.key} className="score-cell">
{formatScore(row[col.key])}
</td>
))}
{visibleColumns.map((col) => {
const tier = scoreTier(row[col.key]);
const cls = tier ? `score-cell tier-${tier.key}` : "score-cell";
return (
<td
key={col.key}
className={cls}
title={tier ? tier.label : undefined}
>
{formatScore(row[col.key])}
</td>
);
})}
</tr>
))}
</tbody>
+87 -20
View File
@@ -1,31 +1,98 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
/**
* Search form - auto-detects search mode:
* all digits → exact soBaoDanh match, otherwise → name LIKE search
*/
export function SearchForm({ onSearch, disabled }) {
const DEBOUNCE_MS = 300;
const MIN_SBD_DIGITS = 3;
const MIN_NAME_CHARS = 2;
// Detect input mode for dynamic hint shown under the field
function detectMode(raw) {
const q = raw.trim();
if (!q) return { mode: "empty", hint: "Gõ SBD (chữ số) hoặc họ tên để tìm" };
if (/^\d+$/.test(q)) {
return q.length >= MIN_SBD_DIGITS
? { mode: "sbd", hint: `Tìm theo số báo danh · khớp chính xác` }
: { mode: "sbd-short", hint: `Cần ít nhất ${MIN_SBD_DIGITS} chữ số` };
}
return q.length >= MIN_NAME_CHARS
? { mode: "name", hint: "Tìm theo họ tên · không phân biệt dấu và hoa/thường" }
: { mode: "name-short", hint: `Cần ít nhất ${MIN_NAME_CHARS} ký tự` };
}
export function SearchForm({ onSearch, onClear, disabled }) {
const [query, setQuery] = useState("");
const inputRef = useRef(null);
const timerRef = useRef(null);
const { mode, hint } = detectMode(query);
const canSearch = mode === "sbd" || mode === "name";
// Debounced live search
useEffect(() => {
clearTimeout(timerRef.current);
if (!canSearch) {
if (mode === "empty") onClear?.();
return;
}
timerRef.current = setTimeout(() => onSearch(query.trim()), DEBOUNCE_MS);
return () => clearTimeout(timerRef.current);
}, [query, canSearch, mode, onSearch, onClear]);
function handleSubmit(e) {
e.preventDefault();
const trimmed = query.trim();
if (trimmed) onSearch(trimmed);
if (canSearch) {
clearTimeout(timerRef.current);
onSearch(query.trim());
}
}
function handleClear() {
setQuery("");
inputRef.current?.focus();
}
return (
<form onSubmit={handleSubmit} className="search-form">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Nhập số báo danh hoặc họ tên..."
disabled={disabled}
autoFocus
/>
<button type="submit" disabled={disabled || !query.trim()}>
Tra cứu
</button>
<form onSubmit={handleSubmit} className="search-form" role="search">
<label htmlFor="search-input" className="search-label">
Tra cứu điểm
</label>
<div className="search-input-row">
<div className={`search-input-wrap mode-${mode}`}>
<input
id="search-input"
ref={inputRef}
type="search"
inputMode="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Ví dụ: 47006585 hoặc Nguyen Van An"
disabled={disabled}
autoFocus
autoComplete="off"
spellCheck={false}
aria-describedby="search-hint"
/>
{query && (
<button
type="button"
className="clear-btn"
onClick={handleClear}
aria-label="Xoá tìm kiếm"
tabIndex={-1}
>
×
</button>
)}
</div>
<button
type="submit"
className="primary-btn"
disabled={disabled || !canSearch}
>
Tra cứu
</button>
</div>
<p id="search-hint" className={`search-hint mode-${mode}`} aria-live="polite">
{hint}
</p>
</form>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useState } from "react";
import { computeBlocks, scoreTier } from "../lib/admission-blocks";
const SUBJECT_LABELS = {
toan: "Toán",
ngu_van: "Ngữ văn",
vat_ly: "Vật lí",
hoa_hoc: "Hóa học",
sinh_hoc: "Sinh học",
khtn: "KHTN",
lich_su: "Lịch sử",
dia_ly: "Địa lí",
gdcd: "GDCD",
khxh: "KHXH",
tieng_anh: "Tiếng Anh",
tieng_phap: "Tiếng Pháp",
tieng_nga: "Tiếng Nga",
tieng_trung: "Tiếng Trung",
};
const SUBJECT_ORDER = [
"toan", "ngu_van",
"vat_ly", "hoa_hoc", "sinh_hoc", "khtn",
"lich_su", "dia_ly", "gdcd", "khxh",
"tieng_anh", "tieng_phap", "tieng_nga", "tieng_trung",
];
function fmt(n) {
return n === null || n === undefined ? "—" : Number(n).toFixed(2);
}
export function StudentDetail({ student }) {
const [copied, setCopied] = useState(false);
const blocks = computeBlocks(student);
const subjects = SUBJECT_ORDER
.filter((k) => student[k] !== null && student[k] !== undefined)
.map((k) => ({ key: k, label: SUBJECT_LABELS[k], score: student[k] }));
function copySbd() {
navigator.clipboard.writeText(student.so_bao_danh).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}
return (
<article className="detail-card" aria-label="Chi tiết điểm thí sinh">
<header className="detail-header">
<div className="detail-identity">
<h2>{student.ho_ten}</h2>
<dl className="detail-meta">
<div>
<dt>SBD</dt>
<dd>
<span className="mono">{student.so_bao_danh}</span>
<button
type="button"
className="copy-btn"
onClick={copySbd}
aria-label="Sao chép số báo danh"
>
{copied ? "✓ Đã chép" : "Chép"}
</button>
</dd>
</div>
{student.ngay_sinh && (
<div>
<dt>Ngày sinh</dt>
<dd className="mono">{student.ngay_sinh}</dd>
</div>
)}
</dl>
</div>
</header>
<section aria-labelledby="subjects-heading">
<h3 id="subjects-heading" className="section-title">Điểm môn thi</h3>
<ul className="score-grid" role="list">
{subjects.map((s) => {
const tier = scoreTier(s.score);
return (
<li
key={s.key}
className={`score-tile tier-${tier.key}`}
aria-label={`${s.label}: ${fmt(s.score)}, ${tier.label}`}
>
<span className="score-subject">{s.label}</span>
<span className="score-value mono">{fmt(s.score)}</span>
<span className="score-tier">
<span className="score-symbol" aria-hidden="true">{tier.symbol}</span>
{tier.label}
</span>
</li>
);
})}
</ul>
</section>
{blocks.length > 0 && (
<section aria-labelledby="blocks-heading">
<h3 id="blocks-heading" className="section-title">
Tổng điểm khối thi
<span className="section-hint">
(Chỉ tính những khối thí sinh có đủ 3 môn)
</span>
</h3>
<ul className="block-list" role="list">
{blocks.map((b) => (
<li key={b.code} className="block-row">
<span className="block-code">{b.code}</span>
<span className="block-label">{b.label}</span>
<span className="block-parts mono">
{b.parts.map((p) => fmt(p.score)).join(" + ")}
</span>
<span className="block-total mono">{b.total.toFixed(2)}</span>
</li>
))}
</ul>
</section>
)}
</article>
);
}
+37
View File
@@ -0,0 +1,37 @@
// Vietnamese university admission-block ("khối thi") definitions for 2017.
// Each block is the sum of 3 subjects; we only compute a block when ALL three
// subject scores exist for the student.
export const ADMISSION_BLOCKS = [
{ code: "A", subjects: ["toan", "vat_ly", "hoa_hoc"], label: "Toán + Lý + Hóa" },
{ code: "A1", subjects: ["toan", "vat_ly", "tieng_anh"], label: "Toán + Lý + Anh" },
{ code: "B", subjects: ["toan", "hoa_hoc", "sinh_hoc"], label: "Toán + Hóa + Sinh" },
{ code: "C", subjects: ["ngu_van", "lich_su", "dia_ly"], label: "Văn + Sử + Địa" },
{ code: "D1", subjects: ["toan", "ngu_van", "tieng_anh"], label: "Toán + Văn + Anh" },
{ code: "D2", subjects: ["toan", "ngu_van", "tieng_nga"], label: "Toán + Văn + Nga" },
{ code: "D3", subjects: ["toan", "ngu_van", "tieng_phap"], label: "Toán + Văn + Pháp" },
{ code: "D4", subjects: ["toan", "ngu_van", "tieng_trung"], label: "Toán + Văn + Trung" },
];
// Returns { code, label, total, parts:[{key,score}] } for every block where
// the student has scores for all three subjects, sorted by total desc.
export function computeBlocks(student) {
const out = [];
for (const b of ADMISSION_BLOCKS) {
const parts = b.subjects.map((k) => ({ key: k, score: student[k] }));
if (parts.some((p) => p.score === null || p.score === undefined)) continue;
const total = parts.reduce((s, p) => s + p.score, 0);
out.push({ code: b.code, label: b.label, total, parts });
}
return out.sort((a, b) => b.total - a.total);
}
// Score tier: used for color + icon coding (meaning never conveyed by color alone)
export function scoreTier(score) {
if (score === null || score === undefined) return null;
if (score < 5) return { key: "poor", symbol: "▽", label: "Chưa đạt" };
if (score < 6.5) return { key: "weak", symbol: "○", label: "Trung bình" };
if (score < 8) return { key: "fair", symbol: "◆", label: "Khá" };
if (score < 9) return { key: "good", symbol: "★", label: "Giỏi" };
return { key: "excellent", symbol: "✦", label: "Xuất sắc" };
}