Skip to content
Open
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
29 changes: 29 additions & 0 deletions app/adminpage/_components/AdminDateSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"use client";

import React from "react";

interface AdminDateSelectorProps {
selectedDate: string;
onSelectDate: (date: string) => void;
}

export const AdminDateSelector = ({
selectedDate,
onSelectDate,
}: AdminDateSelectorProps) => {
const dates = ["오늘", "내일", "모레"];

return (
<div className="flex w-full gap-2">
{dates.map((date) => (
<button
key={date}
onClick={() => onSelectDate(date)}
className={`h-12 flex-1 rounded-lg text-xl font-bold shadow-sm transition-all ${selectedDate === date ? "bg-[#ff775e] text-white" : "bg-[#b3b3b3] text-white"}`}
>
{date}
</button>
))}
</div>
);
};
71 changes: 71 additions & 0 deletions app/adminpage/_components/AdminDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";

import React, { useState, useRef, useEffect } from "react";
import { ChevronDown } from "lucide-react";

interface AdminDropdownProps {
options: string[];
selectedValue: string;
onSelect: (value: string) => void;
height?: string;
className?: string;
}

export const AdminDropdown = ({
options,
selectedValue,
onSelect,
height,
className
}: AdminDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);

return (
<div
ref={dropdownRef}
className={`relative w-full md:w-[136px] h-12 ${className || ""}`}
>
<div
onClick={() => setIsOpen(!isOpen)}
className="w-full h-full bg-[#f4f4f4] rounded-lg border border-[#e5e5e5] px-3 flex items-center justify-between cursor-pointer text-[18px] font-semibold text-black"
>
<span>{selectedValue}</span>
<ChevronDown
size={18}
className={`transition-transform duration-300 text-gray-400 ${isOpen ? "rotate-180" : ""}`}
/>
</div>
Comment on lines +39 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

웹 접근성을 위해 클릭 가능한 div 대신 <button> 요소를 사용하는 것이 좋습니다. type="button"을 명시하고, aria-haspopup="listbox"aria-expanded={isOpen} 속성을 추가하면 스크린 리더가 이 요소의 역할과 상태를 정확히 인지할 수 있습니다.

References
  1. 아이콘 전용 버튼의 aria-label, 키보드 네비게이션 가능 여부를 확인해야 합니다. 현재 div는 키보드로 포커스 및 활성화가 어렵습니다. (link)


{isOpen && (
<div
className="absolute top-full left-0 w-full bg-white border border-[#e5e5e5] mt-1 rounded-lg shadow-lg z-50 overflow-y-auto"
style={{ maxHeight: height || "200px" }}
>
{options.map((option) => (
<div
key={option}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
className="px-3 py-2 hover:bg-[#f4f4f4] cursor-pointer text-[18px] font-medium text-black"
>
{option}
</div>
))}
</div>
)}
</div>
);
};
107 changes: 107 additions & 0 deletions app/adminpage/_components/AdminHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"use client";

import React from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";

interface AdminHeaderProps {
adminSelect?: string;
setAdminSelect?: (val: string) => void;
university?: string;
role?: string;
nickname?: string;
}

export const AdminHeader = ({
adminSelect,
setAdminSelect,
university,
role,
nickname
}: AdminHeaderProps) => {
const router = useRouter();

const goToMainButton = () => {
if (setAdminSelect) setAdminSelect("Main");
router.push("/adminpage/myPage");
};

const goToTeamButton = () => {
if (setAdminSelect) setAdminSelect("팀관리");
router.push("/adminpage/myPage?tab=팀관리");
};

const goToMemberButton = () => {
if (setAdminSelect) setAdminSelect("가입자관리");
router.push("/adminpage/myPage?tab=가입자관리");
};

const getRoleLabel = (role?: string) => {
if (!role) return "";
return role.includes("ADMIN") ? "관리자" : "오퍼레이터";
};

return (
<header className="w-full flex h-[88px] font-sans justify-between items-center px-12 bg-white border-b border-[#cdcdcd] sticky top-0 z-[19999]">
<Image
src="/logo/admin_header_logo.svg"
alt="코매칭 로고"
width={140}
height={40}
className="cursor-pointer"
onClick={() => router.push("/adminpage")}
/>

<nav className="flex justify-center items-center gap-[4.5em] whitespace-nowrap h-full">
<div
onClick={goToMainButton}
className={`px-6 py-[29.5px] text-2xl font-semibold cursor-pointer h-full flex items-center transition-all ${adminSelect === "Main" ? "border-b-4 border-black text-black" : "text-[#808080]"}`}
>
Main
</div>
Comment on lines +57 to +62
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

시맨틱 HTML과 Next.js의 성능 최적화 기능을 활용하기 위해 divonClick 조합 대신 next/link 컴포넌트를 사용하시는 것을 권장합니다. <Link> 컴포넌트는 페이지 사전 로딩(prefetching)을 지원하여 사용자 경험을 향상시키고, 검색 엔진 최적화(SEO)에도 더 유리합니다.

References
  1. 의미에 맞는 태그를 사용해야 합니다. 네비게이션 링크는 div가 아닌 a 태그(Next.js에서는 Link 컴포넌트)가 더 적합합니다. (link)


<div
onClick={goToMemberButton}
className={`px-2 py-[29.5px] text-2xl font-semibold cursor-pointer h-full flex items-center transition-all ${adminSelect === "가입자관리" ? "border-b-4 border-black text-black" : "text-[#808080]"}`}
>
가입자관리
</div>

<div
onClick={goToTeamButton}
className={`px-[10px] py-[29.5px] flex items-center gap-2 cursor-pointer h-full transition-all ${adminSelect === "팀관리" ? "border-b-4 border-black text-black" : "text-[#808080]"}`}
>
<span className="text-2xl font-semibold">팀 관리</span>
<div className="rounded-full bg-[#ff775e] text-[10px] font-bold text-white w-6 h-6 flex justify-center items-center">
3
</div>
</div>
</nav>

<div className="p-2 h-[50px] bg-[#f3f3f3] text-black whitespace-nowrap flex justify-center items-center text-right gap-4 rounded-lg">
<div className="flex flex-col font-medium leading-tight">
<div className="text-[#808080] text-xs font-semibold">{university}</div>
<div className="text-sm">{getRoleLabel(role)} {nickname}님</div>
</div>
<ChevronDown size={16} className="text-gray-400 shrink-0" />
</div>
</header>
);
};

export const AdminRegisterHeader = () => {
const router = useRouter();
return (
<header className="w-full flex h-[88px] font-sans justify-between items-center px-12 bg-white border-b border-[#cdcdcd]">
<Image
src="/logo/admin_header_logo.svg"
alt="코매칭 로고"
width={140}
height={40}
className="cursor-pointer"
onClick={() => router.push("/adminpage")}
/>
</header>
);
};
59 changes: 59 additions & 0 deletions app/adminpage/_components/AdminListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import React from "react";

interface AdminListItemProps {
title: string;
subTitle?: string;
statusText: string;
date: string;
startTime: string;
endTime: string;
onCancel?: () => void;
cancelButtonText?: string;
}

export const AdminListItem = ({
title,
subTitle,
statusText,
date,
startTime,
endTime,
onCancel,
cancelButtonText
}: AdminListItemProps) => {
return (
<div className="w-full border-b border-[#808080] py-6 flex flex-col gap-2">
<div className="flex gap-8 items-center">
<span className="text-2xl font-medium text-[#828282] shrink-0">{statusText}</span>
<span className="text-2xl font-semibold text-[#1a1a1a] truncate">{title} {subTitle && <span className="text-gray-500 font-normal ml-2">{subTitle}</span>}</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 mt-2">
<div className="flex flex-wrap gap-8 text-2xl font-semibold text-black">
<div className="flex gap-4">
<span className="text-[#808080] font-medium w-[137px]">시작일:</span>
<span className="text-[#4d4d4d] font-medium min-w-[120px]">{date}</span>
</div>
<div className="flex gap-4">
<span className="text-[#808080] font-medium w-[96px]">시작 시각:</span>
<span className="text-[#4d4d4d] font-medium min-w-[80px]">{startTime}</span>
</div>
<div className="flex gap-4">
<span className="text-[#808080] font-medium w-[107px]">종료 시각:</span>
<span className="text-[#4d4d4d] font-medium min-w-[80px]">{endTime}</span>
</div>
</div>

{onCancel && (
<button
onClick={onCancel}
className="w-[120px] h-12 bg-[#dd272a] text-white text-xl font-bold rounded-lg shadow-md hover:bg-red-700 transition-colors"
>
{cancelButtonText || "취소"}
</button>
)}
</div>
</div>
);
};
17 changes: 17 additions & 0 deletions app/adminpage/_components/AdminNotAllowed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use client";

import React from "react";
import { Ban } from "lucide-react";

export default function AdminNotAllowed() {
return (
<div className="flex w-full flex-col items-center pt-[200px] font-sans">
<Ban size={100} className="text-[#858585]" />
<div className="mt-4 text-center text-[36px] font-bold text-[#4d4d4d]">
미승인 오퍼레이터입니다.
<br />
관리자의 승인을 대기해 주세요.
</div>
</div>
);
}
41 changes: 41 additions & 0 deletions app/adminpage/_components/AdminTimeRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import React from "react";
import { AdminDropdown } from "./AdminDropdown";

interface AdminTimeRowProps {
hours: string[];
minutes: string[];
selectedHour: string;
selectedMinute: string;
onHourSelect: (hour: string) => void;
onMinuteSelect: (minute: string) => void;
suffix: string;
}

export const AdminTimeRow = ({
hours,
minutes,
selectedHour,
selectedMinute,
onHourSelect,
onMinuteSelect,
suffix,
}: AdminTimeRowProps) => {
return (
<div className="flex flex-wrap items-center gap-4">
<AdminDropdown
options={hours}
selectedValue={selectedHour}
onSelect={onHourSelect}
/>
<span className="text-2xl font-semibold">시</span>
<AdminDropdown
options={minutes}
selectedValue={selectedMinute}
onSelect={onMinuteSelect}
/>
<span className="text-2xl font-semibold">{suffix}</span>
</div>
);
};
21 changes: 21 additions & 0 deletions app/adminpage/_components/AdminWarnItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import React from "react";

interface AdminWarnItemProps {
reason: string;
time: string;
}

export const AdminWarnItem = ({ reason, time }: AdminWarnItemProps) => {
return (
<div className="w-full h-[95px] flex items-center gap-4 border-b border-[#808080]">
<div className="w-[160px] text-2xl font-semibold text-black text-center shrink-0">
{reason}
</div>
<div className="text-2xl font-medium text-[#828282]">
{time}
</div>
</div>
);
};
37 changes: 37 additions & 0 deletions app/adminpage/_components/AdminWarningModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import React from "react";
import { TriangleAlert } from "lucide-react";

interface AdminWarningModalProps {
isOpen: boolean;
onClose: () => void;
message: React.ReactNode;
}

export const AdminWarningModal = ({
isOpen,
onClose,
message,
}: AdminWarningModalProps) => {
if (!isOpen) return null;

return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4">
<div className="flex w-full max-w-[423px] flex-col items-center rounded-[24px] bg-white shadow-2xl">
<div className="flex flex-col items-center gap-4 p-10 text-center">
<TriangleAlert size={60} className="text-[#ff775e]" />
<div className="text-2xl leading-relaxed font-semibold text-black">
{message}
</div>
</div>
<div
className="flex h-14 w-full cursor-pointer items-center justify-center rounded-b-[24px] border-t border-[#b3b3b3] text-xl font-bold text-[#ff775e] hover:bg-gray-50"
onClick={onClose}
>
확인
</div>
</div>
</div>
);
};
Loading
Loading