Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"dependencies": {
"@allcll/common": "workspace:^",
"@allcll/allcll-ui": "workspace:^",
"chart.js": "^4.5.0",
"react-chartjs-2": "^5.3.0",
"@svgr/webpack": "^8.1.0",
"@tanstack/react-query": "^5.83.0",
"@vitejs/plugin-basic-ssl": "^2.1.0",
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/assets/arrow-down-gray.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions packages/admin/src/assets/checkbox-blue.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions packages/admin/src/assets/chevron-left.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions packages/admin/src/assets/circle-check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions packages/admin/src/assets/circle-x.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useEffect } from 'react';
import { Dialog, Flex, Button } from '@allcll/allcll-ui';
import type {
AdminGraduationViewResponse,
CategoryType,
ScopeType,
} from '@/hooks/server/graduation/useAdminGraduationView';
import { CATEGORY_TYPE_LABELS } from './lib/mappers';

type GraduationCourse = AdminGraduationViewResponse['courses']['courses'][number];

interface CategoryEarnedCoursesModalProps {
isOpen: boolean;
onClose: () => void;
categoryType: CategoryType;
majorScope: ScopeType;
courses: GraduationCourse[];
}

function CourseRow({ course }: Readonly<{ course: GraduationCourse }>) {
return (
<Flex
align="items-center"
justify="justify-between"
gap="gap-3"
className="px-3 py-2.5 rounded-md border bg-white border-gray-200"
>
<Flex direction="flex-col" gap="gap-0.5" className="min-w-0">
<span className="text-sm font-medium truncate text-gray-800">{course.curiNm}</span>
<span className="text-xs text-gray-400">{course.curiNo}</span>
</Flex>
<Flex align="items-center" gap="gap-3" className="shrink-0">
<span className="text-xs text-gray-500 whitespace-nowrap">{course.selectedArea}</span>
<span className="text-xs text-gray-500 whitespace-nowrap">{course.credits}학점</span>
</Flex>
</Flex>
);
}

function CategoryEarnedCoursesModal({
isOpen,
onClose,
categoryType,
majorScope,
courses,
}: Readonly<CategoryEarnedCoursesModalProps>) {
useEffect(() => {
if (!isOpen) return;
const original = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = original;
};
}, [isOpen]);

const filtered = courses.filter(c => c.categoryType === categoryType && c.majorScope === majorScope && c.isEarned);
const earnedCredits = filtered.reduce((sum, c) => sum + c.credits, 0);
const earnedCount = filtered.length;
const categoryLabel = CATEGORY_TYPE_LABELS[categoryType];

return (
<Dialog title={`${categoryLabel} 이수 과목`} onClose={onClose} isOpen={isOpen}>
<Dialog.Content>
<Flex direction="flex-col" gap="gap-3" className="min-w-64 md:min-w-96">
{filtered.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-8">해당 카테고리의 이수 과목이 없습니다.</p>
) : (
<>
<Flex justify="justify-end">
<span className="text-xs text-gray-500 shrink-0">
<span className="text-primary-500 font-semibold">{earnedCredits}학점</span> · {earnedCount}과목
</span>
</Flex>
<Flex direction="flex-col" gap="gap-1" className="overflow-y-auto max-h-80 pr-1">
{filtered.map(course => (
<CourseRow key={course.id} course={course} />
))}
</Flex>
</>
)}
</Flex>
</Dialog.Content>

<Dialog.Footer>
<Button variant="primary" size="small" onClick={onClose}>
닫기
</Button>
</Dialog.Footer>
</Dialog>
);
}

export default CategoryEarnedCoursesModal;
112 changes: 112 additions & 0 deletions packages/admin/src/components/graduation/CategoryProgressCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Card, Flex, Button } from '@allcll/allcll-ui';
import ProgressDoughnut from './ProgressDoughnut';
import type {
AdminGraduationViewResponse,
BalanceRequiredArea,
CategoryType,
CriteriaCategory,
ScopeType,
} from '@/hooks/server/graduation/useAdminGraduationView';

type CategoryProgress = AdminGraduationViewResponse['checkData']['categories'][number];

interface CategoryProgressCardProps {
category: CategoryProgress;
label: string;
criteriaCategory?: CriteriaCategory;
onViewCourses?: (
categoryType: CategoryType,
criteriaCategory?: CriteriaCategory,
earnedAreas?: BalanceRequiredArea[],
) => void;
onViewEarnedCourses?: (categoryType: CategoryType, majorScope: ScopeType) => void;
}

function BalanceInfo({ category }: Readonly<{ category: CategoryProgress }>) {
return (
<Flex direction="flex-col" gap="gap-1" className="text-sm">
<Flex justify="justify-end" align="items-center" gap="gap-6">
<span className="text-gray-500">이수 영역</span>
<span>
<span className="text-primary-500 text-xl font-semibold">{category.earnedAreasCnt ?? 0}</span>
<span className="text-gray-400 mx-1">/</span>
<span className="text-lg font-semibold text-gray-600">{category.requiredAreasCnt ?? 0}</span>
</span>
</Flex>
<Flex justify="justify-end" align="items-center" gap="gap-6">
<span className="text-gray-500">이수 학점</span>
<span>
<span className="text-primary-500 text-xl font-semibold">{category.earnedCredits}</span>
<span className="text-gray-400 mx-1">/</span>
<span className="text-lg font-semibold text-gray-600">{category.requiredCredits}</span>
</span>
</Flex>
</Flex>
);
}

function CreditInfo({ category }: Readonly<{ category: CategoryProgress }>) {
return (
<Flex direction="flex-col" gap="gap-1" className="text-sm">
<Flex justify="justify-end" align="items-center" gap="gap-6">
<span className="text-gray-500">필요 학점</span>
<span className="text-lg font-semibold text-gray-600">{category.requiredCredits}</span>
</Flex>
<Flex justify="justify-end" align="items-center" gap="gap-6">
<span className="text-gray-500">이수 학점</span>
<span className="text-primary-500 text-xl font-semibold">{category.earnedCredits}</span>
</Flex>
</Flex>
);
}

function CategoryProgressCard({
category,
label,
criteriaCategory,
onViewCourses,
onViewEarnedCourses,
}: Readonly<CategoryProgressCardProps>) {
const isBalance = category.categoryType === 'BALANCE_REQUIRED' && category.requiredAreasCnt != null;
const doughnutEarned = isBalance ? (category.earnedAreasCnt ?? 0) : category.earnedCredits;
const doughnutRequired = isBalance ? (category.requiredAreasCnt ?? 0) : category.requiredCredits;

const handleViewCourses = () => {
onViewCourses?.(category.categoryType, criteriaCategory, category.earnedAreas ?? undefined);
};

const handleViewEarnedCourses = () => {
onViewEarnedCourses?.(category.categoryType, category.majorScope);
};

return (
<Card variant="outlined" className="h-full">
<Flex direction="flex-col" gap="gap-2" className="h-full">
<div className="text-center">
<span className="text-lg font-bold">{label}</span>
</div>

<Flex justify="justify-center">
<ProgressDoughnut earned={doughnutEarned} required={doughnutRequired} size="medium" />
</Flex>

{isBalance ? <BalanceInfo category={category} /> : <CreditInfo category={category} />}

<div className="w-full mt-auto flex gap-1">
<div className="flex-1 [&>button]:w-full">
<Button variant="outlined" size="small" onClick={handleViewEarnedCourses}>
이수 과목
</Button>
</div>
<div className="flex-1 [&>button]:w-full">
<Button variant="outlined" size="small" onClick={handleViewCourses} disabled={category.satisfied}>
추천 과목
</Button>
</div>
</div>
</Flex>
</Card>
);
}

export default CategoryProgressCard;
Loading
Loading