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ã đị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.

+
+
+ Quá trình + +
+
+ Giữa kỳ + +
+
+ Thực hành + +
+
+ Cuối kỳ + +
+
+
+ +
+ + + +
+
+
+ ); +}; + +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 + {/* 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 && (