diff --git a/next-env.d.ts b/next-env.d.ts index 8bb7c5a..0398e89 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index d455337..e5fa0f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -953,6 +953,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1279,6 +1280,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1752,6 +1754,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2546,6 +2549,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2900,6 +2904,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4244,6 +4249,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4997,6 +5003,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5054,6 +5061,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5268,6 +5276,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5280,6 +5289,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6304,6 +6314,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/App.css b/src/App.css index 050c074..9e44bd3 100644 --- a/src/App.css +++ b/src/App.css @@ -30,6 +30,8 @@ html { --text-muted: #999; --dropdown-border: #454545; --tab-inactive: #3F3F46; + --form-card-bg: #27262B; + --form-input-white: #3f3f46; --success-green: #22C55E; } @@ -48,6 +50,8 @@ html { --text-muted: #555; --dropdown-border: #ccc; --tab-inactive: #e0e0e0; + --form-card-bg: #fff; + --form-input-white: #e0e0e0; --success-green: #16A34A; } @@ -60,6 +64,135 @@ body { margin: 0; } +.add-subject-container { + max-width: 800px; + margin: 0 auto; + width: 100%; + padding-bottom: 40px; +} + +.form-title { + text-align: center; + margin-bottom: 40px; + font-weight: 800; + font-size: 2rem; + color: var(--text-color); +} + +.subject-form-layout { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-section-card { + background: var(--form-card-bg); + border: 1px solid var(--border-color); + padding: 24px; + border-radius: 8px; + display: flex; + flex-direction: column; + color: #fff; + box-sizing: border-box; +} + +.light-mode .form-section-card { + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.form-label { + font-weight: 700; + font-size: 1.2rem; + margin-bottom: 8px; + display: block; + color: var(--text-color); +} + +.form-description { + font-size: 0.9rem; + line-height: 1.5; + color: #818181; + margin-bottom: 16px; +} + +.form-white-input { + background: var(--form-input-white); + border: none; + border-radius: 4px; + height: 48px; + padding: 0 16px; + font-size: 1rem; + width: 100%; + outline: none; + transition: box-shadow 0.2s; + color: var(--text-color); + box-sizing: border-box; +} + +.form-white-input:focus { + box-shadow: 0 0 0 2px var(--primary-purple); +} + + +.input-error { + border: 2px solid #ff4d4f; +} + +.input-error:focus { + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.3); +} + + +.weights-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.weight-item { + display: flex; + flex-direction: column; + gap: 8px; +} + +.weight-label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-color); +} + +.weight-input { + text-align: center; + padding: 0; +} + +.form-actions { + display: flex; + justify-content: center; + margin-top: 20px; +} + +.btn-submit-form { + background: var(--primary-purple); + color: white; + border: none; + padding: 14px 40px; + border-radius: 8px; + font-weight: 800; + font-size: 1.1rem; + cursor: pointer; + transition: transform 0.1s, opacity 0.2s; +} + +.btn-submit-form:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.btn-submit-form:active { + transform: translateY(0); +} + html.light-mode, body.light-mode { background-color: #ffffff !important; diff --git a/src/app/api/create-course-pr/route.ts b/src/app/api/create-course-pr/route.ts new file mode 100644 index 0000000..6b5137b --- /dev/null +++ b/src/app/api/create-course-pr/route.ts @@ -0,0 +1,130 @@ +import { NextRequest, NextResponse } from "next/server"; +const owner = process.env.GITHUB_OWNER!; +const repo = process.env.GITHUB_REPO!; +const token = process.env.GITHUB_TOKEN!; +const baseBranch = process.env.GITHUB_BASE_BRANCH || "main"; + +const filePath = "src/assets/courses_weighted.json"; + +export async function POST(req: NextRequest) { + try { + const newCourse = await req.json(); + + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }; + + const fileRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${baseBranch}`, + { headers } + ); + + if (!fileRes.ok) { + const err = await fileRes.text(); + throw new Error("Cannot read file: " + err); + } + + const fileData = await fileRes.json(); + const sha = fileData.sha; + + const content = Buffer.from(fileData.content, "base64").toString("utf-8"); + + try { + const parsed = JSON.parse(content); + if ( + Array.isArray(parsed) && + parsed.some((c: any) => c.courseCode === newCourse.courseCode) + ) { + return NextResponse.json( + { error: "Course đã tồn tại" }, + { status: 400 } + ); + } + } catch { + throw new Error("File JSON không hợp lệ"); + } + + let updatedText = content.trim(); + + const newItemText = JSON.stringify(newCourse, null, 2); + + if (updatedText.endsWith("]")) { + updatedText = + updatedText.slice(0, -1).trimEnd() + + (updatedText.length > 2 ? ",\n" : "\n") + + newItemText + + "\n]"; + } else { + throw new Error("File không phải JSON array"); + } + + const updatedContent = Buffer.from(updatedText).toString("base64"); + + const branchName = `add-course-${Date.now()}`; + + const refRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`, + { headers } + ); + + const refData = await refRes.json(); + + await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/refs`, + { + method: "POST", + headers, + body: JSON.stringify({ + ref: `refs/heads/${branchName}`, + sha: refData.object.sha, + }), + } + ); + + const updateRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`, + { + method: "PUT", + headers, + body: JSON.stringify({ + message: `Add course ${newCourse.courseCode}`, + content: updatedContent, + sha, + branch: branchName, + }), + } + ); + + if (!updateRes.ok) { + const err = await updateRes.text(); + throw new Error("Commit failed: " + err); + } + + const prRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls`, + { + method: "POST", + headers, + body: JSON.stringify({ + title: `Add course ${newCourse.courseCode}`, + head: branchName, + base: baseBranch, + body: "Added via web form", + }), + } + ); + + const prData = await prRes.json(); + + return NextResponse.json({ + url: prData.html_url, + }); + } catch (err: any) { + console.error(err); + return NextResponse.json( + { error: err.message || "Failed to create PR" }, + { status: 500 } + ); + } +} diff --git a/src/components/AddSubject/AddSubjectForm.tsx b/src/components/AddSubject/AddSubjectForm.tsx new file mode 100644 index 0000000..4dcff51 --- /dev/null +++ b/src/components/AddSubject/AddSubjectForm.tsx @@ -0,0 +1,281 @@ + +import React, { useState } from "react"; +import { Subject, Course } from "../../types"; + +interface AddSubjectFormProps { + onAdd: (subject: Subject) => void; +} + +const AddSubjectForm: React.FC = ({ onAdd }) => { + const [form, setForm] = useState({ + courseCode: "", + courseNameEn: "", + courseNameVi: "", + courseType: "ĐC", + credits: "", + progressWeight: "20", + midtermWeight: "20", + practiceWeight: "20", + finalTermWeight: "40", + }); + + const [isSubmittingPR, setIsSubmittingPR] = useState(false); + const [errors, setErrors] = useState>({}); + + const getTotalWeight = (formData = form) => { + return ( + Number(formData.progressWeight) + + Number(formData.midtermWeight) + + Number(formData.practiceWeight) + + Number(formData.finalTermWeight) + ); + }; + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + + setForm(prev => ({ ...prev, [name]: value })); + + setErrors(prev => { + const newErrors = { ...prev }; + if (value !== "") { + delete newErrors[name]; + } + return newErrors; + }); + }; + + const getCourseObject = (): Course => { + return { + courseCode: form.courseCode, + courseNameEn: form.courseNameEn, + courseNameVi: form.courseNameVi, + courseType: form.courseType, + credits: Number(form.credits) || 0, + defaultWeights: { + progressWeight: (Number(form.progressWeight) || 0) / 100, + practiceWeight: (Number(form.practiceWeight) || 0) / 100, + midtermWeight: (Number(form.midtermWeight) || 0) / 100, + finalTermWeight: (Number(form.finalTermWeight) || 0) / 100, + } + }; + }; + + const validateForm = () => { + const newErrors: Record = {}; + + Object.entries(form).forEach(([key, value]) => { + if (value === "" || value === null) { + newErrors[key] = true; + } + }); + + if (Object.keys(newErrors).length === 0) { + const totalWeight = getTotalWeight(); + + if (totalWeight !== 100) { + newErrors.progressWeight = true; + newErrors.midtermWeight = true; + newErrors.practiceWeight = true; + newErrors.finalTermWeight = true; + } + } + + setErrors(newErrors); + return newErrors; + }; + + const handleCreatePR = async () => { + const errors = validateForm(); + + if (Object.keys(errors).length > 0) { + const hasEmpty = Object.values(form).some(v => v === ""); + + if (hasEmpty) { + alert("Vui lòng nhập đầy đủ thông tin"); + } else { + alert("Tổng trọng số phải bằng 100%"); + } + return; + } + + const courseObj = getCourseObject(); + + try { + setIsSubmittingPR(true); + + const res = await fetch("/api/create-course-pr", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(courseObj), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Tạo PR thất bại"); + } + + alert("Đã tạo Pull Request thành công!"); + if (data.url) { + window.open(data.url, "_blank"); + } + } catch (err: any) { + console.error(err); + alert(err.message || "Có lỗi xảy ra khi tạo PR"); + } finally { + setIsSubmittingPR(false); + } +}; + + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const errors = validateForm(); + + if (Object.keys(errors).length > 0) { + const hasEmpty = Object.values(form).some(v => v === ""); + + if (hasEmpty) { + alert("Vui lòng nhập đầy đủ thông tin"); + } else { + alert("Tổng trọng số phải bằng 100%"); + } + return; + } + + const courseObj = getCourseObject(); + + // Tạo Subject để thêm vào bảng điểm hiện tại + const newSubject: Subject = { + id: `sub-${self.crypto.randomUUID()}`, + courseCode: courseObj.courseCode, + courseName: courseObj.courseNameVi, + credits: courseObj.credits.toString(), + progressScore: "", + midtermScore: "", + practiceScore: "", + finalScore: "", + minProgressScore: "", + minMidtermScore: "", + minPracticeScore: "", + minFinalScore: "", + progressWeight: (courseObj.defaultWeights.progressWeight * 100).toString(), + midtermWeight: (courseObj.defaultWeights.midtermWeight * 100).toString(), + practiceWeight: (courseObj.defaultWeights.practiceWeight * 100).toString(), + finalWeight: (courseObj.defaultWeights.finalTermWeight * 100).toString(), + score: "", + expectedScore: "", + isExpectedManual: false, + }; + + onAdd(newSubject); + }; + + return ( + + Thêm môn + + + + Mã học phần + Mã định danh duy nhất (ví dụ: IT001, CS313,...). + + + + + + Tên học phần (tiếng Việt) + Tên tiếng Việt chính thức của học phần. + + + + + Tên học phần (tiếng Anh) + Tên tiếng Anh chính thức của học phần + + + + + + + Loại học phần + Phân loại theo chương trình đào tạo. + + Đại cương (ĐC) + Cơ sở nhóm ngành (CSNN) + Cơ sở ngành (CSN) + Chuyên ngành (CN) + Chuyên ngành tự chọn (CNTC) + Tốt nghiệp (TN) + Chuyên đề tốt nghiệp (CĐTN) + + + + + Tín chỉ + Số lượng tín chỉ của học phần. + + + + + + Trọng số (%) + Tổng các trọng số phải bằng 100. + + + Quá trình + + + + Giữa kỳ + + + + Thực hành + + + + Cuối kỳ + + + + + + + + + {isSubmittingPR ? "Đang tạo PR..." : "Gửi đóng góp (Tạo PR)"} + + + Thêm vào bảng điểm + + + + + ); +}; + +export default AddSubjectForm; diff --git a/src/components/GradeTable/GradeTable.tsx b/src/components/GradeTable/GradeTable.tsx index f1cbd0c..115fc32 100644 --- a/src/components/GradeTable/GradeTable.tsx +++ b/src/components/GradeTable/GradeTable.tsx @@ -12,7 +12,7 @@ interface GradeTableProps { isCumulativeManual: boolean; setIsCumulativeManual: (value: boolean) => void; updateSubjectField: (s: number, i: number, f: string, v: string) => void; - updateSubjectExpectedScore: (s: number, i: number, v: string) => void; // ← THÊM DÒNG NÀY + updateSubjectExpectedScore: (s: number, i: number, v: string) => void; deleteSemester: (id: string) => void; deleteSubject: (s: number, i: number) => void; openAdvancedModal: (s: number, i: number) => void; @@ -50,7 +50,7 @@ const GradeTable: React.FC = ({ isCumulativeManual, setIsCumulativeManual, updateSubjectField, - updateSubjectExpectedScore, // ← THÊM DÒNG NÀY + updateSubjectExpectedScore, deleteSemester, deleteSubject, openAdvancedModal, @@ -118,7 +118,7 @@ const GradeTable: React.FC = ({ semesters={semesters} setSemesters={setSemesters} updateSubjectField={updateSubjectField} - updateSubjectExpectedScore={updateSubjectExpectedScore} // ← THÊM DÒNG NÀY + updateSubjectExpectedScore={updateSubjectExpectedScore} deleteSemester={deleteSemester} deleteSubject={deleteSubject} openAdvancedModal={openAdvancedModal} diff --git a/src/components/GradeTable/SemesterBlock.tsx b/src/components/GradeTable/SemesterBlock.tsx index 516875c..b2eeaaa 100644 --- a/src/components/GradeTable/SemesterBlock.tsx +++ b/src/components/GradeTable/SemesterBlock.tsx @@ -12,7 +12,7 @@ interface SemesterBlockProps { // Handlers for subjects updateSubjectField: (s: number, i: number, f: string, v: string) => void; - updateSubjectExpectedScore: (s: number, i: number, v: string) => void; // ← THÊM DÒNG NÀY + updateSubjectExpectedScore: (s: number, i: number, v: string) => void; deleteSemester: (id: string) => void; deleteSubject: (s: number, i: number) => void; openAdvancedModal: (s: number, i: number) => void; @@ -50,7 +50,7 @@ const SemesterBlock: React.FC = ({ semesters, setSemesters, updateSubjectField, - updateSubjectExpectedScore, // ← THÊM DÒNG NÀY + updateSubjectExpectedScore, deleteSemester, deleteSubject, openAdvancedModal, @@ -200,7 +200,7 @@ const SemesterBlock: React.FC = ({ semesters={semesters} setSemesters={setSemesters} updateSubjectField={updateSubjectField} - updateSubjectExpectedScore={updateSubjectExpectedScore} // ← THÊM DÒNG NÀY + updateSubjectExpectedScore={updateSubjectExpectedScore} deleteSubject={deleteSubject} openAdvancedModal={openAdvancedModal} openMenu={openMenu} diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index 964f535..45d1a11 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -69,6 +69,12 @@ const Navbar: React.FC = ({ > Hướng dẫn + setActiveTab('add_subject')} + > + Thêm môn + {/* THEME TOGGLE */} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 7c0da3f..324b700 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -7,13 +7,14 @@ import Footer from "../components/Footer/Footer"; import EditModal from "../components/GradeTable/EditModal"; import GradeTable from "../components/GradeTable/GradeTable"; import Instructions from "../components/Instructions/Instructions"; +import AddSubjectForm from "../components/AddSubject/AddSubjectForm"; import { useGradeApp } from "../hooks/useGradeApp"; import { uploadPdf } from "../config/appwrite"; import { Subject, ProcessedPdfData, findCourseByCode, Semester } from "../types"; import { SUBJECTS_DATA } from "../constants"; import { isExemptCourse } from "../utils/gradeUtils"; -export type TabType = "grades" | "instructions"; +export type TabType = "grades" | "instructions" | "add_subject"; export default function Home() { const [activeTab, setActiveTab] = useState("grades"); @@ -413,7 +414,7 @@ export default function Home() { setAddDropdownOpen(null); }} > - {activeTab === "grades" ? ( + {activeTab === "grades" && ( <> @@ -666,7 +667,7 @@ export default function Home() { isCumulativeManual={isCumulativeManual} setIsCumulativeManual={setIsCumulativeManual} updateSubjectField={updateSubjectField} - updateSubjectExpectedScore={updateSubjectExpectedScore} // ← THÊM DÒNG NÀY + updateSubjectExpectedScore={updateSubjectExpectedScore} deleteSemester={deleteSemester} deleteSubject={deleteSubject} openAdvancedModal={openAdvancedModal} @@ -691,8 +692,25 @@ export default function Home() { /> > - ) : ( - + )} + + {activeTab === 'instructions' && } + + {activeTab === 'add_subject' && ( + { + setSemesters(prev => { + const next = [...prev]; + if (next.length === 0) { + next.push({ id: `sem-${self.crypto.randomUUID()}`, name: "Học kỳ 1", subjects: [newSubject] }); + } else { + next[next.length - 1].subjects.push(newSubject); + } + return next; + }); + setActiveTab("grades"); + }} + /> )} {modalOpen && editing && (
Mã định danh duy nhất (ví dụ: IT001, CS313,...).
Tên tiếng Việt chính thức của học phần.
Tên tiếng Anh chính thức của học phần
Phân loại theo chương trình đào tạo.
Số lượng tín chỉ của học phần.
Tổng các trọng số phải bằng 100.