setResults(null)}
+ disabled={loading || !!error}
+ />
{searchError && (
Lỗi truy vấn: {searchError}
)}
-
+ {results && results.length === 1 ? (
+
+ ) : (
+
+ )}
{results && results.length >= MAX_RESULTS && (
diff --git a/2017/src/components/score-table.jsx b/2017/src/components/score-table.jsx
index 8dcb246..a43c576 100644
--- a/2017/src/components/score-table.jsx
+++ b/2017/src/components/score-table.jsx
@@ -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 }) {
{row.so_bao_danh} |
{row.ho_ten} |
{row.ngay_sinh || "—"} |
- {visibleColumns.map((col) => (
-
- {formatScore(row[col.key])}
- |
- ))}
+ {visibleColumns.map((col) => {
+ const tier = scoreTier(row[col.key]);
+ const cls = tier ? `score-cell tier-${tier.key}` : "score-cell";
+ return (
+
+ {formatScore(row[col.key])}
+ |
+ );
+ })}
))}
diff --git a/2017/src/components/search-form.jsx b/2017/src/components/search-form.jsx
index 37545c2..2f65c90 100644
--- a/2017/src/components/search-form.jsx
+++ b/2017/src/components/search-form.jsx
@@ -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 (
-
);
}
diff --git a/2017/src/components/student-detail.jsx b/2017/src/components/student-detail.jsx
new file mode 100644
index 0000000..43508a6
--- /dev/null
+++ b/2017/src/components/student-detail.jsx
@@ -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 (
+
+
+
+
{student.ho_ten}
+
+
+
- SBD
+ -
+ {student.so_bao_danh}
+
+
+
+ {student.ngay_sinh && (
+
+
- Ngày sinh
+ - {student.ngay_sinh}
+
+ )}
+
+
+
+
+
+ Điểm môn thi
+
+ {subjects.map((s) => {
+ const tier = scoreTier(s.score);
+ return (
+ -
+ {s.label}
+ {fmt(s.score)}
+
+ {tier.symbol}
+ {tier.label}
+
+
+ );
+ })}
+
+
+
+ {blocks.length > 0 && (
+
+
+ Tổng điểm khối thi
+
+ (Chỉ tính những khối thí sinh có đủ 3 môn)
+
+
+
+ {blocks.map((b) => (
+ -
+ {b.code}
+ {b.label}
+
+ {b.parts.map((p) => fmt(p.score)).join(" + ")}
+
+ {b.total.toFixed(2)}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/2017/src/lib/admission-blocks.js b/2017/src/lib/admission-blocks.js
new file mode 100644
index 0000000..71b1492
--- /dev/null
+++ b/2017/src/lib/admission-blocks.js
@@ -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" };
+}