diff --git a/app/[locale]/academics/curricula/[code]/page.tsx b/app/[locale]/academics/curricula/[code]/page.tsx index f66f1998d..b60f892af 100644 --- a/app/[locale]/academics/curricula/[code]/page.tsx +++ b/app/[locale]/academics/curricula/[code]/page.tsx @@ -1,10 +1,8 @@ // Revalidate every 5 minutes (has DB calls) export const revalidate = 300; -import Image from 'next/image'; import Link from 'next/link'; import { notFound } from 'next/navigation'; -import { MdEmail, MdPhone } from 'react-icons/md'; import Heading from '~/components/heading'; import ImageHeader from '~/components/image-header'; @@ -66,9 +64,17 @@ export default async function Curriculum({
{text.objectives}:
- {course.objectives.map((objective, index) => ( -
  • {objective}
  • - ))} + {course.objectives.length > 1 ? ( + + ) : ( + course.objectives.map((objective, index) => ( +

    {objective}

    + )) + )}
    {text.similarCourses}: @@ -98,13 +104,19 @@ export default async function Curriculum({ {section.title} -
      - {section.topics.map((topic, subIndex) => ( -
    1. -

      {topic}

      -
    2. - ))} -
    + {section.topics.length > 1 ? ( +
      + {section.topics.map((topic, subIndex) => ( +
    1. +

      {topic}

      +
    2. + ))} +
    + ) : ( + section.topics.map((topic, subIndex) => ( +

    {topic}

    + )) + )}
    ))} diff --git a/app/[locale]/academics/curricula/page.tsx b/app/[locale]/academics/curricula/page.tsx index f1c92ea69..1e7d7fe40 100644 --- a/app/[locale]/academics/curricula/page.tsx +++ b/app/[locale]/academics/curricula/page.tsx @@ -1,27 +1,190 @@ // Revalidate every 5 minutes (has DB calls) export const revalidate = 300; -import { count } from 'drizzle-orm'; +/* ---------------- External ---------------- */ import { Suspense } from 'react'; +import { and, count, eq, inArray, sql} from 'drizzle-orm'; +/* ---------------- Internal (~/) ---------------- */ +import { ClearFiltersButton } from '~/app/faculty-and-staff/client-components'; +import { MobileFilters } from '~/components/mobile-filters'; import Heading from '~/components/heading'; import Loading from '~/components/loading'; import GenericTable from '~/components/ui/generic-table'; import { getTranslations } from '~/i18n/translations'; -import { courses, db } from '~/server/db'; +import { courses, coursesToMajors, db } from '~/server/db'; +import { majors } from '~/server/db/schema/majors.schema'; +import { departments as departmentsTable } from '~/server/db/schema/departments.schema'; +import { MultiCheckbox } from '~/components/inputs'; +/* ---------------- Relative ---------------- */ +import { YearFilterClient } from '~/components/inputs/year-dropdown'; export default async function Curricula({ params: { locale }, searchParams, }: { params: { locale: string }; - searchParams: { page?: string }; + searchParams: { + department?: string | string[]; + page?: string; + degreeLevel?: string | string[]; + major?: string | string[]; + semester?: string | string[]; + year?: string | string[]; + }; }) { + type Degree = typeof majors.$inferSelect.degree; + const { department: departmentName, page: pageParam } = searchParams; + const text = (await getTranslations(locale)).Curricula; - const page = isNaN(Number(searchParams.page ?? '1')) + const page = isNaN(Number(pageParam ?? '1')) ? 1 - : Math.max(Number(searchParams.page ?? '1'), 1); + : Math.max(Number(pageParam ?? '1'), 1); + + const selectedDepartments = Array.isArray(departmentName) + ? departmentName + : departmentName + ? [departmentName] + : []; + + const selectedDegrees = ( + Array.isArray(searchParams.degreeLevel) + ? searchParams.degreeLevel + : searchParams.degreeLevel + ? [searchParams.degreeLevel] + : [] + ) as Degree[]; + + const selectedMajors = Array.isArray(searchParams.major) + ? searchParams.major + : searchParams.major + ? [searchParams.major] + : []; + + const selectedSemesters = Array.isArray(searchParams.semester) + ? searchParams.semester + : searchParams.semester + ? [searchParams.semester] + : []; + + const selectedYears = Array.isArray(searchParams.year) + ? searchParams.year + : searchParams.year + ? [searchParams.year] + : []; + + const departments = await db + .selectDistinct({ + id: departmentsTable.id, + name: departmentsTable.name, + urlName: departmentsTable.urlName, + }) + .from(departmentsTable) + .innerJoin( + majors, + eq(majors.departmentId, departmentsTable.id) + ) + .where( + selectedDegrees.length > 0 + ? inArray(majors.degree, selectedDegrees) + : undefined + ); + + const degrees = await db + .selectDistinct({ degree: majors.degree }) + .from(majors) + .innerJoin( + departmentsTable, + eq(majors.departmentId, departmentsTable.id) + ) + .where( + selectedDepartments.length > 0 + ? inArray(departmentsTable.urlName, selectedDepartments) + : undefined + ); + + const majorsData = await db.query.majors.findMany({ + columns: { + id: true, + name: true, + degree: true, + }, + with: { + department: { + columns: { + urlName: true, + }, + }, + }, + }); + + + const yearsFromDb = await db.select({ year: sql`DISTINCT introduction_year`, }).from(courses); + + const yearOptions = yearsFromDb.map((y) => String(y.year)).sort((a, b) => Number(b) - Number(a)); + + const yearTextMap = Object.fromEntries(yearOptions.map((year) => [year, year])); + + const semestersFromDb = await db + .selectDistinct({ + semester: coursesToMajors.semester, + }) + .from(coursesToMajors) + .innerJoin( + majors, + eq(coursesToMajors.majorId, majors.id) + ) + .innerJoin( + departmentsTable, + eq(majors.departmentId, departmentsTable.id) + ) + .where( + and( + selectedDepartments.length > 0 + ? inArray(departmentsTable.urlName, selectedDepartments) + : undefined, + selectedDegrees.length > 0 + ? inArray(majors.degree, selectedDegrees) + : undefined, + selectedMajors.length > 0 + ? inArray(majors.name, selectedMajors) + : undefined + ) + ); + + const semesterOptions = [ + ...new Set( + semestersFromDb + .map((c) => c.semester) + .filter(Boolean) + .map(String) + ), + ].sort((a, b) => Number(a) - Number(b)); + + const semesterTextMap: Record = Object.fromEntries( + semesterOptions.map((sem) => [sem, `${text.semester} ${sem}`]) + ); + + // 1. Filter the majors based on department/degree selections + const filteredMajors = majorsData.filter((m) => { + if (selectedDepartments.length) { + const deptUrl = m.department?.urlName ?? ''; + if (!selectedDepartments.includes(deptUrl)) return false; + } + if (selectedDegrees.length) { + if (!selectedDegrees.includes(m.degree)) return false; + } + return true; + }); + + // 2. Remove duplicate names (e.g., if CS has both B.Tech and M.Tech) + const uniqueMajorNames = Array.from(new Set(filteredMajors.map((m) => m.name))); + + // 3. Create the text map for the Checkboxes + const majorTextMap = Object.fromEntries( + uniqueMajorNames.map((name) => [name, name]) + ); return ( <> @@ -32,51 +195,274 @@ export default async function Curricula({ text={text.pageTitle} /> - - {/* FIXME: Add input and filters here */} - +
    + + }> + {/* Mobile Filters - Only visible on mobile/tablet */} +
    + d.degree) as readonly string[], + selected: selectedDegrees, + textMap: Object.fromEntries( + degrees.map((d) => [d.degree, d.degree]) + ), + title: text.degree, + }} + majors={{ + options: uniqueMajorNames as readonly string[], + selected: selectedMajors, + textMap: majorTextMap, + title: text.majors, + }} + semester={{ + options: semesterOptions as readonly string[], + selected: selectedSemesters, + textMap: semesterTextMap, + title: text.semester, + }} + yearDropdown={{ + options: yearOptions as readonly string[], + selected: selectedYears.length > 0 ? selectedYears[0] : null, + textMap: yearTextMap, + title: text.year ?? 'Year', + }} + text={{ + filters: text.filters ?? text.filterBy, + filterBy: text.filterBy, + clearAllFilters: text.clearAllFilters ?? 'Clear All Filters', + filter: { + date: text.date ?? 'Date', + startDate: text.startDate ?? 'Start Date', + endDate: text.endDate ?? 'End Date', + day: text.day ?? 'Day', + month: text.month ?? 'Month', + year: text.year ?? 'Year', + }, + }} + /> +
    + + {/* Desktop Filters - Only visible on desktop */} +
    +
    +

    + {text.filterBy} +

    + }> + + + +
    + + {/* Filters Row */} +
    + {/* Department */} + }> + d.urlName) as readonly string[] + } + selected={selectedDepartments} + locale={locale} + textMap={Object.fromEntries( + departments.map((d) => [d.urlName, d.name]) + )} + basePath="/academics/curricula" + variant="accordion" + scrollHeight="h-[256px]" + title={text.department} + /> + + + {/* Degree Card */} + }> + d.degree) as readonly string[]} + selected={selectedDegrees} + locale={locale} + textMap={Object.fromEntries( + degrees.map((d) => [d.degree, d.degree]) + )} + basePath="/academics/curricula" + variant="accordion" + scrollHeight="h-[256px]" + title={text.degree} + /> + + + {/* Semester Card */} + }> + + + + {/* Majors */} + }> + + +
    +
    +
    +
    -
    }> - +
    ); } -const Courses = async ({ page, locale }: { page: number; locale: string }) => { +const Courses = async ({ + page, + locale, + selectedDepartments, + selectedDegrees, + selectedMajors, + selectedSemesters, + selectedYears, +}: { + page: number; + locale: string; + selectedDepartments: string[]; + selectedDegrees: string[]; + selectedMajors: string[]; + selectedSemesters: string[]; + selectedYears: string[]; +}) => { const text = (await getTranslations(locale)).Curricula; + const normalizedDepartments = selectedDepartments.map((d) => d.toLowerCase()); + const normalizedDegrees = selectedDegrees.map((d) => d.toLowerCase()); + const normalizedMajors = selectedMajors.map((m) => m.toLowerCase()); + + const normalizedSemesters = selectedSemesters; + const normalizedYears = selectedYears.map((y) => y); + const coursesData = await db.query.courses.findMany({ - columns: { code: true, title: true }, + columns: { code: true, title: true, introduction_year: true }, with: { coursesToMajors: { columns: { + semester: true, lectureCredits: true, practicalCredits: true, tutorialCredits: true, }, - with: { major: { columns: { name: true } } }, + with: { + major: { + columns: { + name: true, + degree: true, + }, + with: { + department: { + columns: { + name: true, + urlName: true, + }, + }, + }, + }, + }, }, }, }); - // Transform data to flat structure for table - const tableData = coursesData.flatMap(({ code, coursesToMajors, title }) => - coursesToMajors.length === 0 - ? [{ code, title, major: '', credits: '', totalCredits: 0, syllabus: '' }] - : coursesToMajors.map( - ({ lectureCredits, practicalCredits, tutorialCredits, major }) => ({ + const tableData = coursesData.flatMap( + ({ code, title, introduction_year, coursesToMajors }) => + coursesToMajors + .filter(({ major, semester }) => { + const departmentMatch = + normalizedDepartments.length === 0 || + normalizedDepartments.includes( + major.department.urlName.toLowerCase() + ); + + const degreeMatch = + normalizedDegrees.length === 0 || + normalizedDegrees.includes(major.degree.toLowerCase()); + + const majorMatch = + normalizedMajors.length === 0 || + normalizedMajors.includes(major.name.toLowerCase()); + + const semesterMatch = + normalizedSemesters.length === 0 || + normalizedSemesters.includes(String(semester)); + + const yearMatch = + normalizedYears.length === 0 || + normalizedYears.includes(String(introduction_year)); + + return ( + departmentMatch && + degreeMatch && + majorMatch && + semesterMatch && + yearMatch + ); + }) + .map( + ({ + lectureCredits, + practicalCredits, + tutorialCredits, + major, + semester, + }) => ({ code, title, major: major.name, + department: major.department.name, + degree: major.degree, + semester, + year: introduction_year, credits: `${lectureCredits}-${tutorialCredits}-${practicalCredits}`, totalCredits: lectureCredits + practicalCredits + Math.floor(tutorialCredits / 2), - syllabus: `/en/academics/curricula/${code}`, + syllabus: `/${locale}/academics/curricula/${code}`, }) ) ); @@ -85,6 +471,10 @@ const Courses = async ({ page, locale }: { page: number; locale: string }) => { { key: 'code', label: text.code }, { key: 'title', label: text.title }, { key: 'major', label: text.major }, + { key: 'department', label: text.department }, + { key: 'degree', label: text.degree }, + { key: 'year', label: text.year ?? 'Year' }, + { key: 'semester', label: text.semester }, { key: 'credits', label: text.credits }, { key: 'totalCredits', label: text.totalCredits }, { key: 'syllabus', label: text.syllabus }, diff --git a/app/[locale]/academics/departments/[name]/page.tsx b/app/[locale]/academics/departments/[name]/page.tsx index 8fbae0847..e8c83ba86 100644 --- a/app/[locale]/academics/departments/[name]/page.tsx +++ b/app/[locale]/academics/departments/[name]/page.tsx @@ -245,7 +245,7 @@ export default async function Department({ (index === 1 && degree === 'M. Tech.') || (index === 2 && degree === 'Ph. D.') ) - .map(({ name }, index) => ( + .map(({ name, degree }, index) => (
  • + > @@ -281,7 +281,7 @@ export default async function Department({ }, { label: text.laboratories, - href: `/${locale}/academics/departments/laboratories`, + href: `/${locale}/academics/departments/${name}/labs`, icon: HiMiniBeaker, }, { diff --git a/app/[locale]/contributions-for-website-development/contributor-card.tsx b/app/[locale]/contributions-for-website-development/contributor-card.tsx index 520b445a9..c4c0b2010 100644 --- a/app/[locale]/contributions-for-website-development/contributor-card.tsx +++ b/app/[locale]/contributions-for-website-development/contributor-card.tsx @@ -2,6 +2,14 @@ import { useState } from 'react'; import Image from 'next/image'; +import { + FaCode, + FaEnvelope, + FaGithub, + FaLinkedin, + FaPaintBrush, + FaServer, +} from 'react-icons/fa'; import { cn } from '~/lib/utils'; @@ -10,47 +18,213 @@ interface ContributorCardProps { rollNumber: string; image?: string | null; rollNumberLabel: string; + designation: 'developer' | 'designer' | 'devops'; + githubId: string | null; + linkedinId: string | null; } -const FALLBACK_IMAGE = 'fallback/user-image.jpg'; +const FALLBACK_IMAGE = '/fallback/user-image.jpg'; + +const DESIGNATION_BG: Record = { + developer: + 'linear-gradient(135deg, rgba(255,255,255,0.50) 0%, rgba(199,210,254,0.40) 100%)', + designer: + 'linear-gradient(135deg, rgba(255,255,255,0.50) 0%, rgba(251,207,232,0.40) 100%)', + devops: + 'linear-gradient(135deg, rgba(255,255,255,0.50) 0%, rgba(153,246,228,0.38) 100%)', +}; + +const DESIGNATION_SHADOW: Record = + { + developer: + '0 8px 40px rgba(99,102,241,0.3), 0 2px 8px rgba(99,102,241,0.15)', + designer: + '0 8px 40px rgba(236,72,153,0.3), 0 2px 8px rgba(236,72,153,0.15)', + devops: '0 8px 40px rgba(20,184,166,0.28), 0 2px 8px rgba(20,184,166,0.14)', + }; + +const DESIGNATION_HOVER_SHADOW: Record< + ContributorCardProps['designation'], + string +> = { + developer: + '0 16px 56px rgba(99,102,241,0.4), 0 4px 16px rgba(99,102,241,0.2)', + designer: '0 16px 56px rgba(236,72,153,0.4), 0 4px 16px rgba(236,72,153,0.2)', + devops: '0 16px 56px rgba(20,184,166,0.38), 0 4px 16px rgba(20,184,166,0.2)', +}; + +const DESIGNATION_LABEL_COLOR: Record< + ContributorCardProps['designation'], + string +> = { + developer: 'text-indigo-200', + designer: 'text-pink-200', + devops: 'text-teal-200', +}; + +const DESIGNATION_AVATAR_RING: Record< + ContributorCardProps['designation'], + string +> = { + developer: 'ring-indigo-400/50', + designer: 'ring-pink-400/50', + devops: 'ring-teal-400/50', +}; + +const DESIGNATION_ICON: Record< + ContributorCardProps['designation'], + React.ReactNode +> = { + developer: , + designer: , + devops: , +}; + +/* ---------------- SECURITY HELPERS ---------------- */ + +function getSafeLinkedInUrl(linkedinId: string | null) { + if (!linkedinId) return null; + + const trimmed = linkedinId.trim(); + + try { + const url = new URL(trimmed); + + if ( + url.protocol === 'https:' && + (url.hostname === 'www.linkedin.com' || url.hostname === 'linkedin.com') + ) { + return url.toString(); + } + } catch { + // Not a full URL — treat as username + } + + return `https://www.linkedin.com/in/${encodeURIComponent(trimmed)}`; +} + +function getSafeGithubUrl(githubId: string | null) { + if (!githubId) return null; + + const trimmed = githubId.trim(); + + try { + const url = new URL(trimmed); + + if ( + url.protocol === 'https:' && + (url.hostname === 'www.github.com' || url.hostname === 'github.com') + ) { + return url.toString(); + } + } catch { + // Not a full URL — treat as username + } + + return `https://github.com/${encodeURIComponent(trimmed)}`; +} export default function ContributorCard({ name, rollNumber, image, - rollNumberLabel, + designation, + linkedinId, + githubId, }: ContributorCardProps) { const [useFallback, setUseFallback] = useState(false); + const [hovered, setHovered] = useState(false); const imageSrc = useFallback || !image ? FALLBACK_IMAGE : image; + const emailAddress = `${rollNumber.toLowerCase()}@nitkkr.ac.in`; + + const safeLinkedInUrl = getSafeLinkedInUrl(linkedinId); + const safeGithubUrl = getSafeGithubUrl(githubId); return (
    setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + background: DESIGNATION_BG[designation], + boxShadow: hovered + ? DESIGNATION_HOVER_SHADOW[designation] + : DESIGNATION_SHADOW[designation], + }} className={cn( - 'border-primary-200 flex flex-col items-center rounded-xl border bg-neutral-50 p-4 shadow-sm transition-all duration-300 hover:border-primary-500 hover:shadow-lg', - 'w-full sm:w-64' + 'group relative w-[280px] rounded-2xl p-6', + 'transition-all duration-300 ease-out', + hovered && '-translate-y-2' )} > - {/* Contributor Image */} -
    +
    +
    + {DESIGNATION_ICON[designation]} + + {designation} + +
    +
    + +
    {name} setUseFallback(true)} />
    - {/* Contributor Info */} -
    -

    - {name} -

    -

    - {rollNumberLabel}: {rollNumber} -

    +

    + {name} +

    + +

    + {rollNumber} +

    + +
    + {safeGithubUrl && ( + + + + )} + + {safeLinkedInUrl && ( + + + + )} + + + +
    ); diff --git a/app/[locale]/contributions-for-website-development/contributor-timeline.tsx b/app/[locale]/contributions-for-website-development/contributor-timeline.tsx new file mode 100644 index 000000000..d1752d65b --- /dev/null +++ b/app/[locale]/contributions-for-website-development/contributor-timeline.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { Timeline } from '~/components/ui/timeline'; + +import ContributorCard from './contributor-card'; + +interface Contributor { + id: number; + name: string; + rollNumber: string; + image: string | null; + designation: 'developer' | 'designer' | 'devops'; + githubId: string | null; + linkedinId: string | null; +} + +interface Props { + contributorsByYear: Record; + rollNumberLabel: string; +} + +export default function ContributorsTimeline({ + contributorsByYear, + rollNumberLabel, +}: Props) { + const data = Object.keys(contributorsByYear) + .map(Number) + .sort((a, b) => b - a) + .map((year) => { + const startYear = year - 4; + const batchTitle = `${startYear} – ${year} Batch`; + + return { + title: batchTitle, + content: ( +
    +
    + {contributorsByYear[year].map((contributor) => ( + + ))} +
    +
    + ), + }; + }); + + return ( +
    + +
    + ); +} diff --git a/app/[locale]/contributions-for-website-development/page.tsx b/app/[locale]/contributions-for-website-development/page.tsx index 282c42831..55dd583d3 100644 --- a/app/[locale]/contributions-for-website-development/page.tsx +++ b/app/[locale]/contributions-for-website-development/page.tsx @@ -1,4 +1,3 @@ -// filepath: /home/uncanny/Desktop/nitkkr/app/[locale]/contributions-for-website-development/page.tsx // Revalidate every 5 minutes (has DB calls) export const revalidate = 300; @@ -6,15 +5,34 @@ import Heading from '~/components/heading'; import ImageHeader from '~/components/image-header'; import { getTranslations } from '~/i18n/translations'; import { db } from '~/server/db'; +import { getS3Url } from '~/server/s3'; -import ContributorCard from './contributor-card'; +import ContributorsTimeline from './contributor-timeline'; +const base = getS3Url(); + +// Define explicit interface for contributors from database +interface DBContributor { + id: number; + name: string; + rollNumber: string; + designation: 'developer' | 'designer' | 'devops' | null; + passoutYear: number; + image: string | null; + linkedinId: string | null; + githubId: string | null; +} + +// Type for filtered contributors (designation is guaranteed to be non-null) interface Contributor { id: number; name: string; rollNumber: string; + designation: 'developer' | 'designer' | 'devops'; passoutYear: number; image: string | null; + linkedinId: string | null; + githubId: string | null; } export default async function ContributionsPage({ @@ -24,84 +42,94 @@ export default async function ContributionsPage({ }) { const text = (await getTranslations(locale)).WebsiteContributors; - // Fetch contributors from database - const contributors = await db.query.websiteContributors.findMany({ + // Fetch contributors from DB + const contributors = (await db.query.websiteContributors.findMany({ columns: { id: true, name: true, rollNumber: true, passoutYear: true, image: true, + designation: true, + githubId: true, + linkedinId: true, }, - orderBy: (contributor, { desc, asc }) => [ - desc(contributor.passoutYear), - asc(contributor.name), - ], - }); + orderBy: (contributor, { desc, asc }) => [desc(contributor.passoutYear)], + })) as DBContributor[]; - // Group contributors by passout year - const contributorsByYear = contributors.reduce>( - (acc, contributor) => { - const year = contributor.passoutYear; - if (!acc[year]) { - acc[year] = []; - } - acc[year].push(contributor); - return acc; - }, - {} + // Filter out contributors with null designation and type assert + const filteredContributors = contributors.filter( + (contributor): contributor is Contributor => + contributor.designation !== null ); - // Get sorted years (descending order) - const sortedYears = Object.keys(contributorsByYear) + // Group contributors by year (type-safe) + const contributorsByYear = filteredContributors.reduce< + Record + >((acc, contributor) => { + const year = contributor.passoutYear; + if (!acc[year]) acc[year] = []; + acc[year].push(contributor); + return acc; + }, {}); + + const years = Object.keys(contributorsByYear) .map(Number) - .sort((a, b) => b - a); + .sort((a, b) => a - b); return ( <> - {/* Header Section */} + {/* Header */} - {/* Description Section */} -
    -

    - {text.description} -

    -
    + {/* Main Section */} +
    +
    +
    +
    +
    + + {/* Heading + Description */} +
    +

    + {text.description} +

    +
    - {/* Contributors by Year */} -
    - {sortedYears.length === 0 ? ( -

    {text.noContributors}

    - ) : ( - sortedYears.map((year) => ( -
    - {/* Year Heading */} - + {/* Batch navigation */} +
    +
    + {years.map((year) => { + const startYear = year - 4; + return ( + + {startYear} + + ); + })} +
    +
    - {/* Contributors Grid */} -
    - {contributorsByYear[year].map((contributor) => ( - - ))} -
    -
    - )) - )} + {/* Timeline */} +
    ); diff --git a/app/[locale]/student-activities/student-council/page.tsx b/app/[locale]/student-activities/student-council/page.tsx new file mode 100644 index 000000000..25e5f636b --- /dev/null +++ b/app/[locale]/student-activities/student-council/page.tsx @@ -0,0 +1,285 @@ +import NotificationsPanel from '~/components/notifications/notifications-panel'; +import Heading from '~/components/heading'; +import ImageHeader from '~/components/image-header'; +import GenericTable from '~/components/ui/generic-table'; +import FICGroup from '~/components/fic-group'; +import StudentGroup from '~/components/student-group'; +import { getTranslations } from '~/i18n/translations'; +import { db } from '~/server/db'; + +export const revalidate = 300; +export default async function StudentCouncil({ + params: { locale }, +}: { + params: { locale: string }; +}) { + const text = await getTranslations(locale); + + // Fetch all student council data with person details + const studentCouncilMembers = await db.query.studentCouncil.findMany({ + with: { + person: true, + }, + orderBy: (studentCouncil, { asc }) => [ + asc(studentCouncil.category), + asc(studentCouncil.section), + ], + }); + + // Filter members by category "institute functionaries" + const instituteFunctionaries = studentCouncilMembers.filter( + (member) => member.category === 'institute_functionaries' + ); + const coreCommitteeMembers = studentCouncilMembers.filter( + (member) => member.category === 'core_committee' + ); + const nominatedStudentsMembers = studentCouncilMembers.filter( + (member) => member.category === 'nominated_students' + ); + const studentRepresentatives = studentCouncilMembers.filter( + (member) => member.category === 'students_representatives' + ); + + // Separate faculty and students from institute functionaries + const facultyMembers = instituteFunctionaries.filter( + (member) => member.person.type === 'faculty' + ); + + // Get faculty employee IDs + const facultyPersonIds = facultyMembers.map((m) => m.personId); + const facultyData = + facultyPersonIds.length > 0 + ? await db.query.faculty.findMany({ + where: (faculty, { inArray }) => + inArray(faculty.id, facultyPersonIds), + columns: { id: true, employeeId: true }, + }) + : []; + + // Create map for faculty designations + const facultyDesignationMap = new Map( + facultyMembers.map((m) => [m.personId, m.section]) + ); + + // Prepare data for components + const facultyGroupData = facultyData.map((f) => ({ + employeeId: f.employeeId, + designation: facultyDesignationMap.get(f.id) ?? '', + })); + + // Prepare data for core committee (students only) + const coreCommitteePersonIds = coreCommitteeMembers.map((m) => m.personId); + const coreCommitteeStudentData = + coreCommitteePersonIds.length > 0 + ? await db.query.students.findMany({ + where: (students, { inArray }) => + inArray(students.id, coreCommitteePersonIds), + columns: { id: true, rollNumber: true }, + }) + : []; + + const coreCommitteeDesignationMap = new Map( + coreCommitteeMembers.map((m) => [m.personId, m.section]) + ); + + const coreCommitteeGroupData = coreCommitteeStudentData.map((s) => ({ + rollNumber: s.rollNumber, + designation: coreCommitteeDesignationMap.get(s.id) ?? undefined, + })); + + // Prepare data for nominated students + const nominatedStudentsPersonIds = nominatedStudentsMembers.map( + (m) => m.personId + ); + const nominatedStudentsData = + nominatedStudentsPersonIds.length > 0 + ? await db.query.students.findMany({ + where: (students, { inArray }) => + inArray(students.id, nominatedStudentsPersonIds), + columns: { id: true, rollNumber: true }, + }) + : []; + + const nominatedStudentsDesignationMap = new Map( + nominatedStudentsMembers.map((m) => [m.personId, m.section]) + ); + + const nominatedStudentsGroupData = nominatedStudentsData.map((s) => ({ + rollNumber: s.rollNumber, + designation: nominatedStudentsDesignationMap.get(s.id) ?? undefined, + })); + + // Prepare data for student representatives + const studentRepresentativesPersonIds = studentRepresentatives.map( + (m) => m.personId + ); + const studentRepresentativesData = + studentRepresentativesPersonIds.length > 0 + ? await db.query.students.findMany({ + where: (students, { inArray }) => + inArray(students.id, studentRepresentativesPersonIds), + columns: { id: true, rollNumber: true }, + }) + : []; + + // Get academic details for student representatives + const studentRepresentativesAcademicData = + studentRepresentativesPersonIds.length > 0 + ? await db.query.studentAcademicDetails.findMany({ + where: (academicDetails, { inArray }) => + inArray(academicDetails.id, studentRepresentativesPersonIds), + columns: { + id: true, + section: true, + currentSemester: true, + batch: true, + }, + with: { + major: { + columns: { name: true, degree: true }, + }, + }, + }) + : []; + + // Create maps for student representatives + const studentRepresentativesMap = new Map( + studentRepresentativesData.map((s) => [s.id, s]) + ); + const studentRepresentativesAcademicMap = new Map( + studentRepresentativesAcademicData.map((s) => [s.id, s]) + ); + + return ( + <> + + +
    + {/* Main Title with dual elephants */} + + {/* About Description */} +

    {text.StudentCouncil.about}

    +
    + + +
    + + + {/* Display Faculty Members */} + {facultyGroupData.length > 0 && ( + + )} + + + + {/* Display Core Committee Students */} + {coreCommitteeGroupData.length > 0 ? ( +
    + +
    + ) : ( +

    + No core committee members found. +

    + )} + + + + {/* Display Nominated Students */} + {nominatedStudentsGroupData.length > 0 ? ( +
    + +
    + ) : ( +

    + No nominated students found. +

    + )} + + + + {/* Display Student Representatives */} +
    + {studentRepresentatives.length > 0 ? ( + { + const studentData = studentRepresentativesMap.get( + member.personId + ); + const academicData = studentRepresentativesAcademicMap.get( + member.personId + ); + const branchWithDegree = academicData?.major + ? `${academicData.major.name} (${academicData.major.degree})` + : '-'; + return { + roll: studentData?.rollNumber ?? '-', + name: member.person.name, + contact: member.person.telephone + ? `${member.person.telephone}`.trim() + : '-', + branch: branchWithDegree, + batch: academicData?.batch ? `${academicData.batch}` : '-', + }; + })} + /> + ) : ( +

    + No student representatives found. +

    + )} +
    +
    + + ); +} diff --git a/app/[locale]/training-and-placement/page.tsx b/app/[locale]/training-and-placement/page.tsx index 7e6d5a23e..78a02405d 100644 --- a/app/[locale]/training-and-placement/page.tsx +++ b/app/[locale]/training-and-placement/page.tsx @@ -5,13 +5,10 @@ import { FaGlobeAsia, FaRegEnvelope } from 'react-icons/fa'; import { FaGears, FaPhone } from 'react-icons/fa6'; import { MdArticle, MdEmail } from 'react-icons/md'; import { RiBriefcase4Line } from 'react-icons/ri'; +import { sql } from 'drizzle-orm'; -import Heading from '~/components/heading'; -import ImageHeader from '~/components/image-header'; -import NotificationsPanel from '~/components/notifications/notifications-panel'; -import FICGroup from '~/components/fic-group'; -import StudentGroup from '~/components/student-group'; -import ButtonGroup from '~/components/button-group'; +import { cn } from '~/lib/utils'; +import { getTranslations } from '~/i18n/translations'; import { Accordion, AccordionContent, @@ -19,30 +16,18 @@ import { AccordionTrigger, } from '~/components/ui/accordion'; import { ScrollArea } from '~/components/ui'; -import { getTranslations } from '~/i18n/translations'; -import { getS3Url } from '~/server/s3'; -import { cn } from '~/lib/utils'; +import Heading from '~/components/heading'; +import ImageHeader from '~/components/image-header'; +import NotificationsPanel from '~/components/notifications/notifications-panel'; +import FICGroup from '~/components/fic-group'; +import StudentGroup from '~/components/student-group'; +import ButtonGroup from '~/components/button-group'; +import { db } from '~/server/db'; +import { pgPlacementStats } from '~/server/db/schema/placement-stats-pg.schema'; import DirectorCard from '../institute/administration/director/director-card'; import clients from './recruiters'; -// Hardcoded PDF base URL and PDF list for placement stats -const pdfBase = `${getS3Url()}/training-and-placement/placement-stats/`; - -const placementStats: string[] = [ - `${pdfBase}Academic-Session-2024-25.pdf`, - `${pdfBase}Academic-Session-2023-24.pdf`, - `${pdfBase}Academic-Session-2022-23.pdf`, - `${pdfBase}Academic-Session-2021-22.pdf`, - `${pdfBase}Academic-Session-2020-21-FN.pdf`, - `${pdfBase}Academic-Session-2019-20-FN.pdf`, - `${pdfBase}Academic-Session-2018-19-FN.pdf`, - `${pdfBase}Academic-Session-2017-18.pdf`, - `${pdfBase}Academic-Session-2017-18.pdf`, - `${pdfBase}Academic-Session-2017-18-FN.pdf`, - `${pdfBase}Academic-Session-2016-17.pdf`, -]; - const hodProfile = { name: 'Jitender Kumar Chhabra', designation: 'Professor & Head of Department', @@ -68,6 +53,27 @@ export default async function TrainingAndPlacement({ { rollNumber: '12112004', designation: 'Coordinator' }, ]; + // Fetch unique academic sessions from database + const uniqueSessions = await db + .selectDistinct({ + academicSession: pgPlacementStats.academicSession, + }) + .from(pgPlacementStats) + .orderBy(sql`${pgPlacementStats.academicSession} DESC`); + + const sessions = uniqueSessions + .map((s) => s.academicSession) + .sort() + .reverse(); + + // Student coordinators data - replace with actual roll numbers from the database + // const studentCoordinators = [ + // { rollNumber: '12212070', designation: 'President' }, + // { rollNumber: '12112002', designation: 'Vice President' }, + // { rollNumber: '12112003', designation: 'Technical Lead' }, + // { rollNumber: '12112004', designation: 'Coordinator' }, + // ]; + return ( <>
    -
    +
    -
    +
      - {placementStats.map((href, index) => ( -
    1. + {sessions.map((session) => ( +
    2. - {text.stats.content[index] ?? - `Placement Statistics ${index + 1}`} + Academic Session {session}
    3. ))} @@ -267,7 +271,7 @@ export default async function TrainingAndPlacement({
    -
    +
    = { + '2024-25': [ + { + name: 'Academic Session 2024-25', + url: `${pdfBase}Academic-Session-2024-25.pdf`, + }, + ], + '2023-24': [ + { + name: 'Academic Session 2023-24', + url: `${pdfBase}Academic-Session-2023-24.pdf`, + }, + ], + '2022-23': [ + { + name: 'Academic Session 2022-23', + url: `${pdfBase}Academic-Session-2022-23.pdf`, + }, + ], + '2021-22': [ + { + name: 'Academic Session 2021-22', + url: `${pdfBase}Academic-Session-2021-22.pdf`, + }, + ], + '2020-21': [ + { + name: 'Academic Session 2020-21 FN', + url: `${pdfBase}Academic-Session-2020-21-FN.pdf`, + }, + ], + '2019-20': [ + { + name: 'Academic Session 2019-20 FN', + url: `${pdfBase}Academic-Session-2019-20-FN.pdf`, + }, + ], + '2018-19': [ + { + name: 'Academic Session 2018-19', + url: `${pdfBase}Academic-Session-2018-19.pdf`, + }, + { + name: 'Academic Session 2018-19 FN', + url: `${pdfBase}Academic-Session-2018-19-FN.pdf`, + }, + ], + '2017-18': [ + { + name: 'Academic Session 2017-18', + url: `${pdfBase}Academic-Session-2017-18.pdf`, + }, + { + name: 'Academic Session 2017-18 FN', + url: `${pdfBase}Academic-Session-2017-18-FN.pdf`, + }, + ], + '2016-17': [ + { + name: 'Academic Session 2016-17', + url: `${pdfBase}Academic-Session-2016-17.pdf`, + }, + ], +}; + +// Get unique academic sessions sorted in descending order +export function getUniquePlacementSessions(): string[] { + return Object.keys(placementStatsPdfMap).sort().reverse(); +} + +// Get PDFs for a specific academic session +export function getPlacementStatsPdfs( + session: string +): { name: string; url: string }[] | null { + return placementStatsPdfMap[session] ?? null; +} diff --git a/app/[locale]/training-and-placement/stats/[session]/page.tsx b/app/[locale]/training-and-placement/stats/[session]/page.tsx new file mode 100644 index 000000000..d7c0c1a3b --- /dev/null +++ b/app/[locale]/training-and-placement/stats/[session]/page.tsx @@ -0,0 +1,306 @@ +import Link from 'next/link'; +import { MdArticle } from 'react-icons/md'; +import { eq } from 'drizzle-orm'; + +import { cn } from '~/lib/utils'; +import { getTranslations } from '~/i18n/translations'; +import Heading from '~/components/heading'; +import ImageHeader from '~/components/image-header'; +import { + PackageBarChart, + PercentageBarChart, + PgPercentageAccordion, + PlacementBarChart, +} from '~/components/charts'; +import { db } from '~/server/db'; +import { pgPlacementStats } from '~/server/db/schema/placement-stats-pg.schema'; +import { ugPlacementStats } from '~/server/db/schema/placement-stats-ug.schema'; + +import { + getPlacementStatsPdfs, + getUniquePlacementSessions, +} from '../../placement-stats-map'; + +// Helper function to convert numeric strings to numbers +const toNum = (val: unknown): number => { + const parsed = parseFloat(String(val)); + return isNaN(parsed) ? 0 : parsed; +}; + +export async function generateStaticParams() { + const sessions = getUniquePlacementSessions(); + return sessions.map((session) => ({ + session, + locale: 'en', // Add all supported locales here + })); +} + +export default async function PlacementStatsPage({ + params: { locale, session }, +}: { + params: { locale: string; session: string }; +}) { + const text = (await getTranslations(locale)).TrainingAndPlacement; + const pdfs = getPlacementStatsPdfs(session); + + // Verify session exists in database - PG stats + const pgSessionData = await db + .select() + .from(pgPlacementStats) + .where(eq(pgPlacementStats.academicSession, session)); + + // Fetch UG stats + const ugSessionData = await db + .select() + .from(ugPlacementStats) + .where(eq(ugPlacementStats.academicSession, session)); + + if (!pdfs || (pgSessionData.length === 0 && ugSessionData.length === 0)) { + return ( +
    + +

    + The academic session {session} is not available. +

    + + Back to Training & Placement + +
    + ); + } + + return ( + <> + + + {/* UG Statistics Section */} + {ugSessionData.length > 0 && ( +
    + +
    + {/* Placement Numbers Chart */} + +
    + ({ + programme: item.programme, + numberOfEligible: item.numberOfEligible, + numberOfPlaced: item.numberOfPlaced, + numberOfOffers: item.numberOfOffers, + }))} + /> +
    + + {/* Package Chart */} + +
    + ({ + programme: item.programme, + medianPackage: toNum(item.medianPackage), + averagePackage: toNum(item.averagePackage), + highestPackage: toNum(item.highestPackage), + lowestPackage: toNum(item.lowestPackage), + }))} + /> +
    + + {/* Percentage Placement Chart */} + +
    + ({ + programme: item.programme, + percentagePlaced: toNum(item.percentagePlaced) || 0, + eligible: item.numberOfEligible, + placed: item.numberOfPlaced, + }))} + /> +
    +
    +
    + )} + + {/* PG Statistics Section */} + {pgSessionData.length > 0 && ( +
    + +
    + {/* Placement Numbers Chart */} + +
    + ({ + programme: item.programme, + numberOfEligible: item.numberOfEligible, + numberOfPlaced: item.numberOfPlaced, + numberOfOffers: item.numberOfOffers, + }))} + /> +
    + + {/* Package Chart */} + +
    + ({ + programme: item.programme, + medianPackage: toNum(item.medianPackage), + averagePackage: toNum(item.averagePackage), + highestPackage: toNum(item.highestPackage), + lowestPackage: toNum(item.lowestPackage), + }))} + /> +
    + + {/* Percentage Placement Chart — accordion grouped by discipline */} + +
    + { + interface DisciplineGroup { + eligible: number; + placed: number; + programmes: { + programme: string; + eligible: number; + placed: number; + percentagePlaced: number; + }[]; + } + + const groups = pgSessionData.reduce< + Map + >((map, item) => { + const progEligible = item.numberOfEligible; + const progPlaced = item.totalNumberOfPlaced; + + const group = map.get(item.discipline) ?? { + eligible: 0, + placed: 0, + programmes: [], + }; + + group.eligible += progEligible; + group.placed += progPlaced; + + group.programmes.push({ + programme: item.programme, + eligible: progEligible, + placed: progPlaced, + percentagePlaced: + progEligible > 0 + ? (progPlaced / progEligible) * 100 + : 0, + }); + + map.set(item.discipline, group); + return map; + }, new Map()); + + return Array.from(groups.entries()).map( + ([discipline, { eligible, placed, programmes }]) => ({ + discipline, + programmes, + totalEligible: eligible, + totalPlaced: placed, + percentagePlaced: + eligible > 0 ? (placed / eligible) * 100 : 0, + }) + ); + })()} + /> +
    +
    +
    + )} + +
    + +
    +
    +
      + {pdfs.map((pdf) => ( +
    1. + + + {pdf.name} + +
    2. + ))} +
    +
    +
    +
    + +
    + + {text.buttons.backToTrainingPlacement} + +
    + + ); +} diff --git a/components/charts/index.ts b/components/charts/index.ts new file mode 100644 index 000000000..eb33ba7a1 --- /dev/null +++ b/components/charts/index.ts @@ -0,0 +1,4 @@ +export { PlacementBarChart } from './placement-bar-chart'; +export { PackageBarChart } from './package-bar-chart'; +export { PercentageBarChart } from './percentage-bar-chart'; +export { PgPercentageAccordion } from './pg-percentage-accordion'; diff --git a/components/charts/multi-line-axis-tick.tsx b/components/charts/multi-line-axis-tick.tsx new file mode 100644 index 000000000..f567a31c2 --- /dev/null +++ b/components/charts/multi-line-axis-tick.tsx @@ -0,0 +1,58 @@ +/** + * Custom XAxis tick renderer that wraps long programme/discipline names + * into multiple horizontal lines instead of using diagonal text. + * + * Splits on spaces, limits each line to ~14 characters, max 4 lines. + */ +export function MultiLineAxisTick({ + x, + y, + payload, +}: { + x?: number; + y?: number; + payload?: { value: string }; +}) { + if (!payload) return null; + + const MAX_LINE_WIDTH = 14; + const MAX_LINES = 4; + const words = payload.value.split(' '); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + if (current && (current + ' ' + word).length > MAX_LINE_WIDTH) { + lines.push(current); + current = word; + } else { + current = current ? current + ' ' + word : word; + } + } + if (current) lines.push(current); + + // Truncate if too many lines + const displayLines = lines.slice(0, MAX_LINES); + if (lines.length > MAX_LINES) { + displayLines[MAX_LINES - 1] = + displayLines[MAX_LINES - 1].slice(0, -1) + '…'; + } + + return ( + + {displayLines.map((line, i) => ( + + {line} + + ))} + + ); +} diff --git a/components/charts/package-bar-chart.tsx b/components/charts/package-bar-chart.tsx new file mode 100644 index 000000000..918973ab2 --- /dev/null +++ b/components/charts/package-bar-chart.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { MultiLineAxisTick } from './multi-line-axis-tick'; + +interface PackageDataPoint { + programme: string; + medianPackage: number; + averagePackage: number; + highestPackage: number; + lowestPackage: number; +} + +interface PackageBarChartProps { + data: PackageDataPoint[]; +} + +const BAR_GROUP_WIDTH = 120; +const MIN_CHART_WIDTH = 600; + +export function PackageBarChart({ data }: PackageBarChartProps) { + const formattedData = data.map((item) => ({ + programme: item.programme, + lowest: parseFloat(item.lowestPackage.toString()), + median: parseFloat(item.medianPackage.toString()), + average: parseFloat(item.averagePackage.toString()), + highest: parseFloat(item.highestPackage.toString()), + })); + + const chartWidth = Math.max(MIN_CHART_WIDTH, data.length * BAR_GROUP_WIDTH); + + return ( +
    +
    +
    + + + + } + /> + + { + if (typeof value === 'number') { + return `₹${value.toFixed(2)} LPA`; + } + return value ?? ''; + }} + labelFormatter={(label: unknown) => String(label)} + /> + + + + + + + +
    +
    +
    + ); +} diff --git a/components/charts/percentage-bar-chart.tsx b/components/charts/percentage-bar-chart.tsx new file mode 100644 index 000000000..b620d6e7c --- /dev/null +++ b/components/charts/percentage-bar-chart.tsx @@ -0,0 +1,98 @@ +interface PercentageDataPoint { + programme: string; + percentagePlaced: number; + eligible: number; + placed: number; +} + +interface PercentageBarChartProps { + data: PercentageDataPoint[]; +} + +function getBarColor(pct: number): string { + if (pct >= 80) return '#C5291D'; + if (pct >= 60) return '#E13F32'; + if (pct >= 40) return '#E7695F'; + return '#EE928B'; +} + +function getBgColor(pct: number): string { + if (pct >= 80) return 'rgba(197,41,29,0.12)'; + if (pct >= 60) return 'rgba(225,63,50,0.12)'; + if (pct >= 40) return 'rgba(231,105,95,0.12)'; + return 'rgba(238,146,139,0.12)'; +} + +export function PercentageBarChart({ data }: PercentageBarChartProps) { + const sorted = [...data].sort( + (a, b) => b.percentagePlaced - a.percentagePlaced + ); + + return ( +
    + {/* Color legend */} +
    + {[ + { color: '#C5291D', label: '≥ 80%' }, + { color: '#E13F32', label: '60 – 79%' }, + { color: '#E7695F', label: '40 – 59%' }, + { color: '#EE928B', label: '< 40%' }, + ].map(({ color, label }) => ( + + + {label} + + ))} +
    + + {/* Progress bar rows */} +
    + {sorted.map((entry) => { + const pct = Math.min(Math.max(entry.percentagePlaced, 0), 100); + const color = getBarColor(pct); + const bg = getBgColor(pct); + + return ( +
    + {/* Top row: programme name + stats */} +
    + + {entry.programme} + +
    + + {entry.placed}/{entry.eligible} + + + {pct.toFixed(1)}% + +
    +
    + + {/* Progress bar */} +
    +
    +
    +
    + ); + })} +
    +
    + ); +} diff --git a/components/charts/pg-percentage-accordion.tsx b/components/charts/pg-percentage-accordion.tsx new file mode 100644 index 000000000..1d50f8c73 --- /dev/null +++ b/components/charts/pg-percentage-accordion.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '~/components/ui'; + +interface ProgrammeEntry { + programme: string; + eligible: number; + placed: number; + percentagePlaced: number; +} + +interface DisciplineGroup { + discipline: string; + programmes: ProgrammeEntry[]; + totalEligible: number; + totalPlaced: number; + percentagePlaced: number; +} + +interface PgPercentageAccordionProps { + data: DisciplineGroup[]; +} + +function getBarColor(pct: number): string { + if (pct >= 80) return '#C5291D'; + if (pct >= 60) return '#E13F32'; + if (pct >= 40) return '#E7695F'; + return '#EE928B'; +} + +function getBgColor(pct: number): string { + if (pct >= 80) return 'rgba(197,41,29,0.12)'; + if (pct >= 60) return 'rgba(225,63,50,0.12)'; + if (pct >= 40) return 'rgba(231,105,95,0.12)'; + return 'rgba(238,146,139,0.12)'; +} + +function ProgressRow({ + label, + pct, + eligible, + placed, + bold = false, +}: { + label: string; + pct: number; + eligible: number; + placed: number; + bold?: boolean; +}) { + const clamped = Math.min(Math.max(pct, 0), 100); + const color = getBarColor(clamped); + + return ( +
    +
    + + {label} + +
    + + {placed}/{eligible} placed + + + {clamped.toFixed(1)}% + +
    +
    +
    +
    +
    +
    + ); +} + +export function PgPercentageAccordion({ data }: PgPercentageAccordionProps) { + const sorted = [...data].sort( + (a, b) => b.percentagePlaced - a.percentagePlaced + ); + + return ( +
    + {/* Color legend */} +
    + {[ + { color: '#C5291D', label: '≥ 80%' }, + { color: '#E13F32', label: '60 – 79%' }, + { color: '#E7695F', label: '40 – 59%' }, + { color: '#EE928B', label: '< 40%' }, + ].map(({ color, label }) => ( + + + {label} + + ))} +
    + + + {sorted.map((group) => { + const pct = Math.min(Math.max(group.percentagePlaced, 0), 100); + const bg = getBgColor(pct); + + return ( + + +
    + +
    +
    + + +
    + {group.programmes + .sort((a, b) => b.percentagePlaced - a.percentagePlaced) + .map((prog) => ( + + ))} +
    +
    +
    + ); + })} +
    +
    + ); +} diff --git a/components/charts/placement-bar-chart.tsx b/components/charts/placement-bar-chart.tsx new file mode 100644 index 000000000..00d22c12e --- /dev/null +++ b/components/charts/placement-bar-chart.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { MultiLineAxisTick } from './multi-line-axis-tick'; + +interface ChartDataPoint { + programme: string; + numberOfEligible: number; + numberOfPlaced: number; + numberOfOffers: number; + percentagePlaced?: number; +} + +interface PlacementBarChartProps { + data: ChartDataPoint[]; +} + +const BAR_GROUP_WIDTH = 100; +const MIN_CHART_WIDTH = 600; + +export function PlacementBarChart({ data }: PlacementBarChartProps) { + const chartWidth = Math.max(MIN_CHART_WIDTH, data.length * BAR_GROUP_WIDTH); + + return ( +
    +
    +
    + + + + } + /> + + { + if (typeof value === 'number') { + return value.toFixed(0); + } + return value ?? ''; + }} + /> + + + + + + +
    +
    +
    + ); +} diff --git a/components/inputs/year-dropdown.tsx b/components/inputs/year-dropdown.tsx new file mode 100644 index 000000000..682ee65c3 --- /dev/null +++ b/components/inputs/year-dropdown.tsx @@ -0,0 +1,89 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +interface Option { + label: string; + value: string; +} + +export function YearFilterClient({ + yearOptions, +}: { + yearOptions: readonly string[]; +}) { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const years: Option[] = useMemo(() => { + return yearOptions.map((year) => ({ + label: year, + value: year, + })); + }, [yearOptions]); + + const selected = searchParams?.get('year') ?? ''; + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const params = new URLSearchParams(searchParams.toString()); + params.delete('year'); + if (value) { + params.set('year', value); + } + router.push(`${pathname}?${params.toString()}`); + }; + + return ( +
    + + + {/* Custom Arrow */} +
    + + + +
    +
    + ); +} diff --git a/components/locale-switcher.tsx b/components/locale-switcher.tsx index 0de536f7f..8ae8fbf90 100644 --- a/components/locale-switcher.tsx +++ b/components/locale-switcher.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import { usePathname, useSearchParams } from 'next/navigation'; export default function LocaleSwitcher({ children, @@ -12,13 +12,20 @@ export default function LocaleSwitcher({ className?: string; locale: string; }) { - const pathName = usePathname(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const redirectedPathName = (locale: string) => { - if (!pathName) return '/'; + if (!pathname) return '/'; - const segments = pathName.split('/'); + const segments = pathname.split('/'); segments[1] = locale; - return segments.join('/'); + + const newPath = segments.join('/'); + + const queryString = searchParams.toString(); + + return queryString ? `${newPath}?${queryString}` : newPath; }; return ( @@ -31,4 +38,4 @@ export default function LocaleSwitcher({ {children} ); -} +} \ No newline at end of file diff --git a/components/mobile-filters.tsx b/components/mobile-filters.tsx index 2687784ba..d37706b53 100644 --- a/components/mobile-filters.tsx +++ b/components/mobile-filters.tsx @@ -1,13 +1,15 @@ 'use client'; import { createPortal } from 'react-dom'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { startTransition, useEffect, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { FaTimes } from 'react-icons/fa'; import { MdFilterList } from 'react-icons/md'; import { ScrollArea } from '~/components/ui/scroll-area'; import { DateRangeFilter, MultiCheckbox } from '~/components/inputs'; +import { YearFilterClient } from '~/components/inputs/year-dropdown'; import { cn } from '~/lib/utils'; // -------------- Mobile Filters ------------------ @@ -50,6 +52,30 @@ interface DegreeLevelFilterConfig { title: string; } +/** Optional majors filter config */ +interface MajorsFilterConfig { + options: readonly string[]; + selected: string[]; + textMap: Record; + title: string; +} + +/** Optional semester filter config */ +interface SemesterFilterConfig { + options: readonly string[]; + selected: string[]; + textMap: Record; + title: string; +} + +/** Optional year dropdown filter config */ +interface YearFilterDropdownConfig { + options: readonly string[]; + selected: string | null; + textMap: Record; + title: string; +} + interface MobileFiltersProps { locale: string; /** Base path used for building filter URLs, e.g. '/events' or '/notifications' */ @@ -62,6 +88,12 @@ interface MobileFiltersProps { department?: DepartmentFilterConfig; /** Degree level filter — pass to enable */ degreeLevel?: DegreeLevelFilterConfig; + /** Majors filter — pass to enable */ + majors?: MajorsFilterConfig; + /** Semester filter — pass to enable */ + semester?: SemesterFilterConfig; + /** Year dropdown filter — pass to enable */ + yearDropdown?: YearFilterDropdownConfig; text: { filters: string; filterBy: string; @@ -86,9 +118,14 @@ export function MobileFilters({ category, department, degreeLevel, + majors, + semester, + yearDropdown, text, className, }: MobileFiltersProps) { + const router = useRouter(); + const searchParams = useSearchParams(); const [open, setOpen] = useState(false); const [isAnimating, setIsAnimating] = useState(false); const panelRef = useRef(null); @@ -131,6 +168,9 @@ export function MobileFilters({ (category?.selected.length ?? 0) + (department?.selected.length ?? 0) + (degreeLevel?.selected.length ?? 0) + + (majors?.selected.length ?? 0) + + (semester?.selected.length ?? 0) + + (yearDropdown?.selected ? 1 : 0) + dateFiltersCount; return ( @@ -250,6 +290,21 @@ export function MobileFilters({
    )} + {/* Year Dropdown Filter (conditional) */} + {yearDropdown && ( +
    +
    +

    + {yearDropdown.title} +

    + +
    + +
    +
    +
    + )} + {/* Department Filter (conditional) */} {department && (
    @@ -281,6 +336,36 @@ export function MobileFilters({ />
    )} + + {/* Majors Filter (conditional) */} + {majors && ( +
    + +
    + )} + + {/* Semester Filter (conditional) */} + {semester && ( +
    + +
    + )}
    diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx index fdfdb6623..e45f3ff8e 100644 --- a/components/ui/accordion.tsx +++ b/components/ui/accordion.tsx @@ -49,9 +49,11 @@ const AccordionContent = React.forwardRef< className="overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" {...props} > -

    +

    {children} -

    +
    )); diff --git a/components/ui/generic-table.tsx b/components/ui/generic-table.tsx index a9b314e27..b3af73e71 100644 --- a/components/ui/generic-table.tsx +++ b/components/ui/generic-table.tsx @@ -121,8 +121,8 @@ export default function GenericTable>({ return (
    -
    - +
    +
    {showSerialNo && {serialNoLabel}} @@ -159,6 +159,16 @@ export default function GenericTable>({ } > + {visibleData.length === 0 && ( + + + No courses found for the selected filters. + + + )} {visibleData.map((item, rowIndex) => ( >({ )} ); -} +} \ No newline at end of file diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx index 8872b6293..3b8431169 100644 --- a/components/ui/scroll-area.tsx +++ b/components/ui/scroll-area.tsx @@ -48,4 +48,4 @@ const ScrollBar = forwardRef< )); ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; -export { ScrollArea, ScrollBar }; +export { ScrollArea, ScrollBar }; \ No newline at end of file diff --git a/components/ui/table.tsx b/components/ui/table.tsx index 8b7756e06..22329ba15 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -2,28 +2,38 @@ import * as React from 'react'; import { cn } from '~/lib/utils'; -import { ScrollArea } from '.'; - const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes & { scrollAreaClassName?: string; } >(({ className, scrollAreaClassName, ...props }, ref) => ( - -
    - + {/* + Single div handles BOTH axes natively: + - overflow-x-auto → horizontal scrollbar when table is wider than container + - overflow-y-auto → vertical scrollbar when rows exceed the max-height + Using native browser scroll avoids Radix ScrollArea's overflow:hidden interference. + */} +
    +
    + + )); Table.displayName = 'Table'; @@ -73,7 +83,7 @@ const TableRow = React.forwardRef< { + const ref = useRef(null); + const containerRef = useRef(null); + const [height, setHeight] = useState(0); + + useEffect(() => { + if (ref.current) { + const rect = ref.current.getBoundingClientRect(); + setHeight(rect.height); + } + }, [ref]); + + const { scrollYProgress } = useScroll({ + target: containerRef, + offset: ['start 10%', 'end 50%'], + }); + + const heightTransform = useTransform(scrollYProgress, [0, 1], [0, height]); + const opacityTransform = useTransform(scrollYProgress, [0, 0.1], [0, 1]); + + return ( +
    +
    + {data.map((item, index) => ( +
    +
    +
    +
    +
    +

    + {item.title} +

    +
    + +
    +

    + {item.title} +

    + {item.content} + {''} +
    +
    + ))} +
    + +
    +
    +
    + ); +}; diff --git a/i18n/en.ts b/i18n/en.ts index 5f78af5d9..3bdd20eb6 100644 --- a/i18n/en.ts +++ b/i18n/en.ts @@ -51,6 +51,7 @@ import { nccEn, nssEn, laboratoriesEn, + studentCouncilEn, } from './translate'; import { Label } from '@radix-ui/react-label'; @@ -106,6 +107,7 @@ const text: Translations = { NCC: nccEn, NSS: nssEn, Laboratories: laboratoriesEn, + StudentCouncil: studentCouncilEn, }; export default text; diff --git a/i18n/hi.ts b/i18n/hi.ts index 068a496a5..acd1ae190 100644 --- a/i18n/hi.ts +++ b/i18n/hi.ts @@ -51,6 +51,7 @@ import { nccHi, nssHi, laboratoriesHi, + studentCouncilHi, } from './translate'; const text: Translations = { @@ -105,5 +106,6 @@ const text: Translations = { NCC: nccHi, NSS: nssHi, Laboratories: laboratoriesHi, + StudentCouncil: studentCouncilHi, }; export default text; diff --git a/i18n/translate/curricula.ts b/i18n/translate/curricula.ts index 96d070a93..b7e7d7519 100644 --- a/i18n/translate/curricula.ts +++ b/i18n/translate/curricula.ts @@ -5,9 +5,22 @@ export interface CurriculaTranslations { code: string; title: string; major: string; + department: string; + degree: string; credits: string; totalCredits: string; syllabus: string; + filterBy: string; + filters?: string; // for mobile filters button text + clearAllFilters?: string; + date?: string; + startDate?: string; + endDate?: string; + day?: string; + month?: string; + majors: string; + semester: string; + year?: string; } export const curriculaEn: CurriculaTranslations = { @@ -15,17 +28,43 @@ export const curriculaEn: CurriculaTranslations = { code: 'Code', title: 'Title', major: 'Major', + department: 'Department', + degree: 'Degree', credits: 'L-T-P', totalCredits: 'Credits', syllabus: 'Syllabus', + filterBy: 'Filter By', + filters: 'Filters', + clearAllFilters: 'Clear All Filters', + date: 'Date', + startDate: 'Start Date', + endDate: 'End Date', + day: 'Day', + month: 'Month', + majors: 'Majors', + semester: 'Semester', + year: 'Year', }; export const curriculaHi: CurriculaTranslations = { pageTitle: 'पाठ्यक्रम', code: 'कोड', title: 'शीर्षक', - major: 'क्रमादेश', - credits: 'एल-टी-पी', - totalCredits: 'क्रेडिट्स', - syllabus: 'पाठ्यक्रम', + major: 'विशेषज्ञता', + department: 'विभाग', + degree: 'डिग्री', + filterBy: 'फ़िल्टर करें', + filters: 'फ़िल्टर', + clearAllFilters: 'सभी फ़िल्टर हटाएँ', + date: 'तारीख', + startDate: 'प्रारंभ तिथि', + endDate: 'समाप्ति तिथि', + day: 'दिन', + month: 'महीना', + majors: 'विशेषज्ञता', + semester: 'सेमेस्टर', + credits: 'व्याख्यान-अभ्यास-व्यावहारिक', + totalCredits: 'कुल क्रेडिट', + syllabus: 'पाठ्यक्रम विवरण', + year: 'वर्ष', }; diff --git a/i18n/translate/index.ts b/i18n/translate/index.ts index cad305a55..889e5e887 100644 --- a/i18n/translate/index.ts +++ b/i18n/translate/index.ts @@ -49,3 +49,4 @@ export * from './tenders'; export * from './ncc'; export * from './nss'; export * from './laboratories'; +export * from './student-council'; diff --git a/i18n/translate/student-council.ts b/i18n/translate/student-council.ts new file mode 100644 index 000000000..d878e8de5 --- /dev/null +++ b/i18n/translate/student-council.ts @@ -0,0 +1,55 @@ +// Student council translations + +export interface StudentCouncilTranslations { + title: string; + about: string; + headings: { + instituteFunctionaries: string; + coreCommittee: string; + nominatedStudents: string; + studentsRepresentatives: string; + }; + tableHeaders: { + roll: string; + name: string; + contact: string; + branch: string; + batch: string; + }; +} + +export const studentCouncilEn: StudentCouncilTranslations = { + title: 'Student Council', + about: `The Student Council of NIT Kurukshetra acts as the democratic voice of the student fraternity, serving as a vital bridge between the administration and the learners. We are dedicated to ensuring student welfare, organizing grand fests, and upholding the institute's legacy. In reality, our primary job description involves running laps around the Admin Block chasing signatures for budget approvals that never come on time. We don’t just solve problems; we create WhatsApp groups to discuss them for three hours and achieve nothing. We are the chosen few who get to wear formal clothes while everyone else is in shorts, giving us the illusion of authority. We fight for your rights, but mostly we fight for extra food coupons during the fest. We are the Student Council: Overworked, under-slept, and powered entirely by tea and skipped lectures.`, + headings: { + instituteFunctionaries: 'Institute Functionaries', + coreCommittee: 'Core Committee', + nominatedStudents: 'Nominated Students', + studentsRepresentatives: `Student's Representatives`, + }, + tableHeaders: { + roll: 'Roll Number', + name: 'Name', + contact: 'Contact No.', + branch: 'Branch & programme', + batch: 'Batch', + }, +}; + +export const studentCouncilHi: StudentCouncilTranslations = { + title: 'छात्र परिषद', + about: `एनआईटी कुरुक्षेत्र की छात्र परिषद छात्र समुदाय की लोकतांत्रिक आवाज के रूप में कार्य करती है, प्रशासन और शिक्षार्थियों के बीच एक महत्वपूर्ण पुल के रूप में सेवा करती है। हम छात्र कल्याण सुनिश्चित करने, भव्य उत्सव आयोजित करने और संस्थान की विरासत को बनाए रखने के लिए समर्पित हैं। वास्तव में, हमारा प्राथमिक कार्य विवरण बजट अनुमोदन के लिए हस्ताक्षर के लिए प्रशासनिक ब्लॉक के चारों ओर दौड़ना है जो कभी समय पर नहीं आते। हम केवल समस्याओं को हल नहीं करते; हम उन्हें चर्चा करने के लिए तीन घंटे तक व्हाट्सएप समूह बनाते हैं और कुछ भी हासिल नहीं करते हैं। हम चुने हुए कुछ लोग हैं जो औपचारिक कपड़े पहनते हैं जबकि बाकी सभी शॉर्ट्स में होते हैं, जिससे हमें अधिकार का भ्रम होता है। हम आपके अधिकारों के लिए लड़ते हैं, लेकिन ज्यादातर हम उत्सव के दौरान अतिरिक्त भोजन कूपन के लिए लड़ते हैं। हम छात्र परिषद हैं: ओवरवर्केड, कम सोए हुए, और पूरी तरह से चाय और छोड़ी गई कक्षाओं से संचालित।`, + headings: { + instituteFunctionaries: 'संस्थान के अधिकारी', + coreCommittee: 'कोर समिति', + nominatedStudents: 'नॉमिनेटेड स्टूडेंट्स', + studentsRepresentatives: 'छात्र प्रतिनिधि', + }, + tableHeaders: { + roll: 'रोल नंबर', + name: 'नाम', + contact: 'संपर्क नंबर', + branch: 'शाखा और कार्यक्रम', + batch: 'बैच', + }, +}; diff --git a/i18n/translate/training-and-placement.ts b/i18n/translate/training-and-placement.ts index e0932d6c2..1b5aff1e3 100644 --- a/i18n/translate/training-and-placement.ts +++ b/i18n/translate/training-and-placement.ts @@ -3,6 +3,7 @@ export interface TrainingAndPlacementTranslations { headings: { ourrecruiters: string; stats: string; + placementReports: string; guidelines: string; about: string; faq: string; @@ -15,6 +16,17 @@ export interface TrainingAndPlacementTranslations { fic: string; placementcoordinators: string; studentcoordinators: string; + pgStatistics: string; + ugStatistics: string; + }; + charts: { + placementDistribution: string; + packageStatistics: string; + placementPercentage: string; + placementPercentageByDiscipline: string; + }; + buttons: { + backToTrainingPlacement: string; }; Dean: { name: string; @@ -66,6 +78,7 @@ export const trainingAndPlacementEn: TrainingAndPlacementTranslations = { headings: { ourrecruiters: 'Our Recruiters', stats: 'Placement Statistics', + placementReports: 'Placement Reports', guidelines: 'Guidelines', about: 'About us', forrecruiters: 'For Recruiters', @@ -78,6 +91,17 @@ export const trainingAndPlacementEn: TrainingAndPlacementTranslations = { fic: 'Faculty In-Charge', placementcoordinators: 'Placement Coordinators', studentcoordinators: 'Student Coordinators', + pgStatistics: 'PG (Postgraduate) Placement Statistics', + ugStatistics: 'UG (Undergraduate) Placement Statistics', + }, + charts: { + placementDistribution: 'Placement Distribution by Programme', + packageStatistics: 'Package Statistics by Programme', + placementPercentage: 'Placement Percentage by Programme', + placementPercentageByDiscipline: 'Placement Percentage by Discipline', + }, + buttons: { + backToTrainingPlacement: '← Back to Training & Placement', }, Dean: { name: 'Prof. XYZ', @@ -217,6 +241,7 @@ export const trainingAndPlacementHi: TrainingAndPlacementTranslations = { headings: { ourrecruiters: 'हमारे भर्तीकर्ता', stats: 'प्लेसमेंट आंकड़े', + placementReports: 'प्लेसमेंट रिपोर्ट', guidelines: 'दिशानिर्देश', about: 'हमारे बारे में', faq: 'अक्सर पूछे जाने वाले प्रश्न', @@ -229,6 +254,17 @@ export const trainingAndPlacementHi: TrainingAndPlacementTranslations = { fic: 'प्रभारी संकाय', placementcoordinators: 'प्लेसमेंट समन्वयक', studentcoordinators: 'छात्र समन्वयक', + pgStatistics: 'PG (स्नातकोत्तर) प्लेसमेंट आंकड़े', + ugStatistics: 'UG (स्नातक) प्लेसमेंट आंकड़े', + }, + charts: { + placementDistribution: 'प्रोग्राम के अनुसार प्लेसमेंट वितरण', + packageStatistics: 'प्रोग्राम के अनुसार पैकेज आंकड़े', + placementPercentage: 'प्रोग्राम के अनुसार प्लेसमेंट प्रतिशत', + placementPercentageByDiscipline: 'विषय के अनुसार प्लेसमेंट प्रतिशत', + }, + buttons: { + backToTrainingPlacement: '← प्रशिक्षण और प्लेसमेंट पर वापस जाएँ', }, Dean: { name: 'प्रो. XYZ', diff --git a/i18n/translations.ts b/i18n/translations.ts index ffdadb6d4..97ad39760 100644 --- a/i18n/translations.ts +++ b/i18n/translations.ts @@ -51,6 +51,7 @@ import type { NCCTranslations, NSSTranslations, LaboratoriesTranslations, + StudentCouncilTranslations, } from './translate'; export async function getTranslations(locale: string): Promise { @@ -111,6 +112,7 @@ export type { TrainingAndPlacementTranslations, NCCTranslations, NSSTranslations, + StudentCouncilTranslations, }; export interface Translations { @@ -165,4 +167,5 @@ export interface Translations { NCC: NCCTranslations; NSS: NSSTranslations; Laboratories: LaboratoriesTranslations; + StudentCouncil: StudentCouncilTranslations; } diff --git a/infra/Production.md b/infra/Production.md new file mode 100644 index 000000000..938a59b63 --- /dev/null +++ b/infra/Production.md @@ -0,0 +1,346 @@ +# 🏗 NITKKR Website – Production Infrastructure Documentation + +## 📌 Overview + +This document describes the complete self-hosted production infrastructure for the NIT Kurukshetra website. + +The system is fully migrated from cloud services (AWS S3, Neon DB) to an **on-premise self-hosted architecture** running inside the institute network. + +This infrastructure is designed to be: + +- Cost free (self-hosted) +- Fully controllable +- Easily restorable +- Scalable +- Production grade + +--- + +# 🧠 Architecture Overview + +## Core Stack + +| Layer | Technology | +| ------------------ | ------------------------ | +| Frontend + Backend | Next.js (App Router) | +| Database | PostgreSQL (self-hosted) | +| File Storage | MinIO (S3 replacement) | +| Search | Typesense | +| Reverse Proxy | Caddy | +| Containerization | Docker + Docker Compose | +| Auth | Google OAuth | +| Deployment | Self-hosted Linux server | + +--- + +# 🖥 Server Specs + +| Resource | Value | +| -------- | ------------------ | +| CPU | 20 cores | +| RAM | 16 GB | +| Storage | Local disk | +| OS | Ubuntu 24.04 | +| Network | Institute intranet | + +--- + +# 📁 Directory Structure + +``` +/home/saac/ + + ├── nitkkr-prod/ + │ ├── docker-compose.yml + │ ├── Caddyfile + │ ├── data/ + │ │ ├── postgres/ + │ │ ├── minio/ + │ │ └── typesense/ + │ + ├── nitkkr-app/ + │ ├── source code + │ └── .env.production +``` + +### Critical data location + +``` +~/nitkkr-prod/data/ +``` + +This folder contains: + +- full database +- all uploaded files +- search index + +If this exists → system recoverable. + +--- + +# 🐳 Services Running + +## 1. Postgres (Primary DB) + +Replaced Neon cloud DB. + +**Container:** `nitkkr-postgres` +**Image:** postgres:16-alpine +**Internal only (not public)** + +Connection string inside docker: + +``` +postgresql://nitkkr:PASS@postgres:5432/nitkkr_prod +``` + +Persistent storage: + +``` +data/postgres/ +``` + +--- + +## 2. MinIO (S3 Replacement) + +Replaced AWS S3. + +Used for: + +- faculty images +- documents +- uploads +- static assets + +Bucket: + +``` +nitkkr-public +``` + +Public access via Caddy: + +``` +http://SERVER_IP/files/nitkkr-public/... +``` + +Persistent storage: + +``` +data/minio/ +``` + +--- + +## 3. Typesense (Search) + +Used for: + +- faculty search +- course search +- internal indexing + +Persistent storage: + +``` +data/typesense/ +``` + +Indexes generated from Postgres. + +--- + +## 4. Next.js App + +Runs inside docker. + +Startup: + +``` +npm run build && npm start +``` + +Connected to: + +- Postgres +- MinIO +- Typesense + +Environment file: + +``` +.env.production +``` + +Important env: + +``` +DATABASE_URL +NEXTAUTH_URL +GOOGLE_CLIENT_ID +GOOGLE_CLIENT_SECRET +STORAGE_PROVIDER=minio +``` + +--- + +## 5. Caddy Reverse Proxy + +Handles: + +- website routing +- MinIO public files +- domain redirects +- HTTPS (future) + +Example routing: + +``` +/files/* → minio +/ → nextjs app +``` + +--- + +# 🚀 Deployment Commands + +## Start production + +``` +docker compose up -d +``` + +## Rebuild after code change + +``` +docker compose up -d --build app +``` + +## Restart only app + +``` +docker compose restart app +``` + +## Stop system + +``` +docker compose down +``` + +--- + +# 💾 Backup Strategy + +All critical data: + +``` +~/nitkkr-prod/data/ +``` + +## Weekly backup command + +``` +tar -czf nitkkr-backup-$(date +%F).tar.gz ~/nitkkr-prod/data +``` + +Store backup: + +- local laptop +- external drive +- cloud (optional) + +--- + +# ♻ Restore Process + +If server crashes: + +### 1. Install docker + +### 2. Copy backup + +``` +tar -xzf backup.tar.gz +``` + +### 3. Start system + +``` +docker compose up -d +``` + +Site fully restored. + +--- + +# 🔄 Updating Website + +``` +cd ~/nitkkr-app +git pull +docker compose up -d --build app +``` + +Zero data loss. + +--- + +# 📊 Logs & Debugging + +Check containers: + +``` +docker ps +``` + +App logs: + +``` +docker logs nitkkr-app +``` + +DB logs: + +``` +docker logs nitkkr-postgres +``` + +Caddy logs: + +``` +docker logs nitkkr-caddy +``` + +--- + +# 🌐 Future: Public Domain Setup + +When domain comes: + +We will enable: + +- HTTPS SSL auto +- secure cookies +- production OAuth +- public access + +Time required: ~10 min + +--- + +# 🔐 Safety Rules + +Never delete: + +``` +nitkkr-prod/data/ +``` + +Always backup before: + +- OS reinstall +- disk change +- major migration diff --git a/package-lock.json b/package-lock.json index 2fe0d076d..4405caa61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "react-dom": "^18", "react-hook-form": "^7.51.5", "react-icons": "^5.4.0", + "recharts": "^3.7.0", "tailwind-merge": "^2.2.1", "typesense": "^1.8.2", "usehooks-ts": "^3.0.1", @@ -2032,40 +2033,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@neondatabase/serverless": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.0.tgz", - "integrity": "sha512-XWmEeWpBXIoksZSDN74kftfTnXFEGZ3iX8jbANWBc+ag6dsiQuvuR4LgB0WdCOKMb5AQgjqgufc0TgAsZubUYw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "^22.10.2", - "@types/pg": "^8.8.0" - }, - "engines": { - "node": ">=19.0.0" - } - }, - "node_modules/@neondatabase/serverless/node_modules/@types/node": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", - "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@neondatabase/serverless/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@next/env": { "version": "14.2.26", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.26.tgz", @@ -3688,6 +3655,42 @@ "@babel/runtime": "^7.13.10" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -4350,6 +4353,18 @@ "node": ">=16.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -4868,6 +4883,69 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6043,9 +6121,10 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", "engines": { "node": ">=6" } @@ -6164,6 +6243,127 @@ "node": ">= 10" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6187,6 +6387,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6618,6 +6824,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", @@ -7143,6 +7359,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7708,6 +7930,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -7763,6 +7995,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -9743,8 +9984,30 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } }, "node_modules/react-remove-scroll": { "version": "2.5.5", @@ -9834,6 +10097,51 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -9876,6 +10184,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -10528,6 +10842,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10835,6 +11155,28 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/package.json b/package.json index 834a2f25f..b949e59df 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "react-dom": "^18", "react-hook-form": "^7.51.5", "react-icons": "^5.4.0", + "recharts": "^3.7.0", "tailwind-merge": "^2.2.1", "typesense": "^1.8.2", "usehooks-ts": "^3.0.1", diff --git a/server/db/schema/index.ts b/server/db/schema/index.ts index 9625430ad..5be2ba2f6 100644 --- a/server/db/schema/index.ts +++ b/server/db/schema/index.ts @@ -50,3 +50,5 @@ export * from './senate-agenda-minutes.schema'; export * from './scsa_minutes.schema'; export * from './website-contributors.schema'; export * from './student-council.schema'; +export * from './placement-stats-pg.schema'; +export * from './placement-stats-ug.schema';