feat: 학생 자율 가입 + 인적사항 수집 + 경리용 명단/CSV
- 테스트 계정(student1~4) 제거, 시드는 mentor1만 (MENTOR_INITIAL_PASSWORD 환경변수 지원) - 학생은 회원가입으로 등록 — 첫날 첫 실습 겸 본인이 정보 입력 - StudentProfile 엔티티(users와 1:1 분리): 회사이메일(로컬만 저장+도메인 고정)·생년월일· 연락처·주소·학교·전공·비상연락처. 주민번호·계좌 등 민감정보는 제외(종이 인사 절차) - 회원가입 폼 확장 + 이메일 실시간 미리보기(hong → hong@awesomedev.dev) - 멘토 페이지: 수습생 명단 표 + CSV 다운로드(BOM·이스케이프로 엑셀 한글 안전) — 경리 부담↓ - 이메일 아이디 중복·형식 검증(서버+프론트) 버그 수정: 명단 조회 LazyInitializationException(open-in-view:false) → JOIN FETCH로 해결 (보안 리뷰가 경고했던 그 함정 — 주석으로 학습 포인트화) 검증: 가입 201 / 이메일중복 409 / 형식오류 400 / 명단 200 / CSV(헤더·BOM·이스케이프) / 학생→명단 403 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
387180dbfc
commit
ecb1298d93
@ -78,26 +78,26 @@ public class SeedDataLoader implements ApplicationRunner {
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 5명 생성.
|
||||
* 초기 비밀번호는 전원 "mirim2026!" — 첫 로그인 후 변경을 권장한다.
|
||||
* 학습 포인트: DB엔 BCrypt 해시만 저장된다. 원문 비밀번호는 어디에도 남지 않는다.
|
||||
* 초기 사용자: 멘토 계정 하나만 만든다. 학생은 각자 회원가입한다.
|
||||
*
|
||||
* 학습 포인트: 왜 학생 계정을 시드로 안 만드나?
|
||||
* - 학생은 첫날 직접 가입하며 "받은 비밀번호를 바꾸는 것이 첫 일"을 몸으로 배운다.
|
||||
* - 개인정보(이름·이메일·연락처)를 본인이 입력하는 것이 정확하다(대신 넣으면 오타·불일치).
|
||||
* 멘토만 시드로 두는 이유: 학생을 관리할 사람이 처음부터 있어야 하기 때문이다.
|
||||
*
|
||||
* ⚠️ MENTOR_INITIAL_PASSWORD 환경변수로 초기 비밀번호를 넣을 수 있다(운영 권장).
|
||||
* 없으면 개발용 기본값을 쓰되, 이 값은 소스에 보이므로 첫 로그인 후 반드시 바꿔야 한다.
|
||||
*/
|
||||
private void seedUsers() {
|
||||
if (userRepository.count() > 0) {
|
||||
return; // 이미 사용자가 있으면 건너뛴다
|
||||
}
|
||||
// 학습 포인트: encode()는 일부러 느린 연산(BCrypt)이라 한 번만 호출해서 재사용한다.
|
||||
// 다섯 명이 같은 해시를 공유해도 문제없다 — 어차피 같은 초기 비밀번호이고,
|
||||
// 각자 비밀번호를 바꾸는 순간 서로 다른 해시가 된다.
|
||||
String initialPasswordHash = passwordEncoder.encode("mirim2026!");
|
||||
String initialPassword = System.getenv().getOrDefault("MENTOR_INITIAL_PASSWORD", "mirim2026!");
|
||||
String passwordHash = passwordEncoder.encode(initialPassword);
|
||||
|
||||
userRepository.save(new User("student1", initialPasswordHash, "수습생1", "STUDENT", "DEV"));
|
||||
userRepository.save(new User("student2", initialPasswordHash, "수습생2", "STUDENT", "DEV"));
|
||||
userRepository.save(new User("student3", initialPasswordHash, "수습생3", "STUDENT", "DEV"));
|
||||
userRepository.save(new User("student4", initialPasswordHash, "수습생4", "STUDENT", "DESIGN"));
|
||||
userRepository.save(new User("mentor1", initialPasswordHash, "멘토", "MENTOR", "NONE"));
|
||||
userRepository.save(new User("mentor1", passwordHash, "멘토", "MENTOR", "NONE"));
|
||||
|
||||
log.info("초기 사용자 5명을 생성했습니다 (student1~4, mentor1).");
|
||||
log.info("멘토 계정(mentor1)을 생성했습니다. 학생은 회원가입으로 등록합니다.");
|
||||
}
|
||||
|
||||
private void seedDocuments() {
|
||||
|
||||
@ -0,0 +1,144 @@
|
||||
package dev.awesomedev.mirim.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 수습생의 인적사항(개인정보)을 담는 테이블. 로그인 정보(users)와 "1:1"로 짝을 이룬다.
|
||||
*
|
||||
* 학습 포인트 ① — 왜 users에 다 넣지 않고 테이블을 나누나?
|
||||
* 1) 관심사 분리: users는 "로그인에 필요한 것"만, 여기는 "인사에 필요한 것"만 담는다.
|
||||
* 2) 개인정보 보호(privacy by design): SQL 입문 코스에서 학생들이 users 테이블을 실습한다.
|
||||
* 개인정보를 users에 섞으면 실습하다 동료의 주소·연락처가 그대로 보인다.
|
||||
* 테이블을 나누면 users에는 개인정보가 없어, 실습과 개인정보 보호가 부딪치지 않는다.
|
||||
*
|
||||
* 학습 포인트 ② — 여기에 주민등록번호·계좌번호는 없다(일부러).
|
||||
* 그런 민감정보는 법적으로 암호화 저장 의무가 있고 유출 시 책임이 무겁다.
|
||||
* 학습용 플랫폼에 두지 않고, 근로계약서(종이 인사 절차)로 받는다.
|
||||
* "받을 수 있는 것"과 "받아도 되는 것"은 다르다 — 꼭 필요한 최소한만 모은다(최소 수집 원칙).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "student_profile")
|
||||
public class StudentProfile {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 이 개인정보가 누구 것인지. users와 1:1.
|
||||
* 학습 포인트: @OneToOne도 DB에선 외래키(user_id) 컬럼 하나다. unique를 걸어
|
||||
* "한 사용자에 프로필 하나"를 DB가 보장하게 한다.
|
||||
*/
|
||||
@OneToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", unique = true)
|
||||
private User user;
|
||||
|
||||
/**
|
||||
* 회사 이메일의 아이디 부분만 저장한다(예: "hong").
|
||||
* 학습 포인트: 도메인(@awesomedev.dev)은 회사 공통이라 저장하지 않고 붙여서 만든다.
|
||||
* 같은 값을 모든 행에 반복 저장하는 건 낭비이고, 회사 도메인이 바뀌면 한 곳만 고치면 된다.
|
||||
* 전체 이메일이 필요할 때 fullEmail()로 조립한다.
|
||||
*/
|
||||
@Column(name = "company_email_local", nullable = false, unique = true, length = 40)
|
||||
private String companyEmailLocal;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String phone;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "text")
|
||||
private String address;
|
||||
|
||||
/** 출신 학교 */
|
||||
@Column(nullable = false, length = 60)
|
||||
private String school;
|
||||
|
||||
/** 전공/학과 */
|
||||
@Column(nullable = false, length = 60)
|
||||
private String major;
|
||||
|
||||
/** 비상 연락처 — 이름 / 관계 / 전화번호 */
|
||||
@Column(nullable = false, length = 30)
|
||||
private String emergencyName;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String emergencyRelation;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String emergencyPhone;
|
||||
|
||||
protected StudentProfile() {
|
||||
}
|
||||
|
||||
public StudentProfile(User user, String companyEmailLocal, LocalDate birthDate, String phone,
|
||||
String address, String school, String major,
|
||||
String emergencyName, String emergencyRelation, String emergencyPhone) {
|
||||
this.user = user;
|
||||
this.companyEmailLocal = companyEmailLocal;
|
||||
this.birthDate = birthDate;
|
||||
this.phone = phone;
|
||||
this.address = address;
|
||||
this.school = school;
|
||||
this.major = major;
|
||||
this.emergencyName = emergencyName;
|
||||
this.emergencyRelation = emergencyRelation;
|
||||
this.emergencyPhone = emergencyPhone;
|
||||
}
|
||||
|
||||
/** 회사 도메인은 여기 한 곳에서만 정의한다. 바뀌면 이 상수만 고친다. */
|
||||
public static final String COMPANY_DOMAIN = "awesomedev.dev";
|
||||
|
||||
/** 저장된 아이디 부분에 회사 도메인을 붙여 전체 이메일을 만든다. */
|
||||
public String fullEmail() {
|
||||
return companyEmailLocal + "@" + COMPANY_DOMAIN;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public String getCompanyEmailLocal() {
|
||||
return companyEmailLocal;
|
||||
}
|
||||
|
||||
public LocalDate getBirthDate() {
|
||||
return birthDate;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public String getSchool() {
|
||||
return school;
|
||||
}
|
||||
|
||||
public String getMajor() {
|
||||
return major;
|
||||
}
|
||||
|
||||
public String getEmergencyName() {
|
||||
return emergencyName;
|
||||
}
|
||||
|
||||
public String getEmergencyRelation() {
|
||||
return emergencyRelation;
|
||||
}
|
||||
|
||||
public String getEmergencyPhone() {
|
||||
return emergencyPhone;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package dev.awesomedev.mirim.repository;
|
||||
|
||||
import dev.awesomedev.mirim.domain.StudentProfile;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일: student_profile 테이블의 DB 접근 창구.
|
||||
*/
|
||||
public interface StudentProfileRepository extends JpaRepository<StudentProfile, Long> {
|
||||
|
||||
/** 회사 이메일 아이디 중복 확인 (가입 시). */
|
||||
boolean existsByCompanyEmailLocal(String companyEmailLocal);
|
||||
|
||||
/** 특정 사용자의 프로필. */
|
||||
Optional<StudentProfile> findByUser(User user);
|
||||
|
||||
/**
|
||||
* 전체 프로필을 User까지 함께 가져온다 (멘토 명단·CSV용).
|
||||
*
|
||||
* 학습 포인트 — JOIN FETCH는 왜 필요한가?
|
||||
* StudentProfile.user는 LAZY(필요할 때 따로 조회)다. 그런데 명단을 만들 때
|
||||
* 프로필마다 user의 이름·트랙이 필요하다. 그냥 findAll로 가져오면,
|
||||
* 나중에 p.getUser()를 부르는 순간 SELECT가 또 나가는데(N+1), 그 시점이
|
||||
* 트랜잭션 밖이면 "no session" 에러로 터진다(open-in-view: false라서).
|
||||
* JOIN FETCH는 "프로필 조회할 때 user도 한 번에 JOIN해서 가져와라"는 지시다.
|
||||
* → 한 번의 쿼리로 다 채워오니 N+1도, LazyInitializationException도 없다.
|
||||
* (이 함정은 Progress·Submission 엔티티 주석에서도 반복 등장한다 — 그만큼 흔하다.)
|
||||
*/
|
||||
@Query("SELECT p FROM StudentProfile p JOIN FETCH p.user ORDER BY p.id ASC")
|
||||
List<StudentProfile> findAllWithUser();
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import dev.awesomedev.mirim.domain.StudentProfile;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.repository.StudentProfileRepository;
|
||||
import dev.awesomedev.mirim.repository.UserRepository;
|
||||
import dev.awesomedev.mirim.web.dto.SignupRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
@ -21,10 +24,14 @@ import java.util.List;
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final StudentProfileRepository studentProfileRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
public AuthService(UserRepository userRepository,
|
||||
StudentProfileRepository studentProfileRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.studentProfileRepository = studentProfileRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@ -58,20 +65,35 @@ public class AuthService {
|
||||
* 멘토 계정이 필요하면 관리자가 별도 절차(현재는 시드 데이터)로 만든다.
|
||||
*/
|
||||
@Transactional
|
||||
public User signup(String username, String rawPassword, String name, String track) {
|
||||
public User signup(SignupRequest req) {
|
||||
// 학습 포인트: 중복 검사와 저장 사이에 다른 요청이 끼어들 수도 있지만(경쟁 상태),
|
||||
// username 컬럼에 unique 제약이 걸려 있어 최악의 경우에도 DB가 중복을 막아 준다.
|
||||
// unique 제약이 걸려 있어 최악의 경우에도 DB가 중복을 막아 준다.
|
||||
// 이 사전 검사는 사용자에게 친절한 에러 메시지를 주기 위한 것이다.
|
||||
if (userRepository.existsByUsername(username)) {
|
||||
if (userRepository.existsByUsername(req.username())) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다: " + username);
|
||||
HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다.");
|
||||
}
|
||||
// 회사 이메일 아이디도 중복될 수 없다 — hong@awesomedev.dev를 두 명이 쓸 수 없으니까.
|
||||
if (studentProfileRepository.existsByCompanyEmailLocal(req.companyEmailLocal())) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT, "이미 사용 중인 회사 이메일 아이디입니다.");
|
||||
}
|
||||
|
||||
// 비밀번호는 반드시 BCrypt로 해시해서 저장한다. 원문은 DB 어디에도 남지 않는다.
|
||||
String passwordHash = passwordEncoder.encode(rawPassword);
|
||||
String passwordHash = passwordEncoder.encode(req.password());
|
||||
|
||||
User user = new User(username, passwordHash, name, "STUDENT", track);
|
||||
return userRepository.save(user);
|
||||
// 학습 포인트: 한 트랜잭션 안에서 users와 student_profile을 함께 저장한다.
|
||||
// 둘 중 하나만 저장되고 끊기면 "계정은 있는데 개인정보가 없는" 반쪽 상태가 된다.
|
||||
// @Transactional이 "둘 다 되거나, 둘 다 안 되거나"를 보장한다(원자성).
|
||||
User user = userRepository.save(
|
||||
new User(req.username(), passwordHash, req.name(), "STUDENT", req.track()));
|
||||
|
||||
studentProfileRepository.save(new StudentProfile(
|
||||
user, req.companyEmailLocal(), req.birthDate(), req.phone(), req.address(),
|
||||
req.school(), req.major(),
|
||||
req.emergencyName(), req.emergencyRelation(), req.emergencyPhone()));
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import dev.awesomedev.mirim.domain.StudentProfile;
|
||||
import dev.awesomedev.mirim.repository.StudentProfileRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 수습생 인적사항 조회와, 경리 담당자를 위한 CSV 명단 만들기.
|
||||
*
|
||||
* 학습 포인트: "화면에 표로 보여주기"와 "파일로 내려받기"는 같은 데이터의 다른 출구다.
|
||||
* 데이터를 꺼내는 일(리포지토리)은 공유하고, 표현(JSON이냐 CSV냐)만 다르게 한다.
|
||||
*/
|
||||
@Service
|
||||
public class StudentProfileService {
|
||||
|
||||
private final StudentProfileRepository studentProfileRepository;
|
||||
|
||||
public StudentProfileService(StudentProfileRepository studentProfileRepository) {
|
||||
this.studentProfileRepository = studentProfileRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<StudentProfile> allProfiles() {
|
||||
// JOIN FETCH로 User까지 한 번에 — 트랜잭션 밖에서 getUser()를 써도 안전하다.
|
||||
return studentProfileRepository.findAllWithUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 수습생 인적사항을 CSV 문자열로 만든다. 경리 담당자가 엑셀로 열어 쓴다.
|
||||
*
|
||||
* 학습 포인트 ① — CSV 이스케이프.
|
||||
* 주소에 쉼표(,)가 들어가면 열이 밀린다. 그래서 각 값을 큰따옴표로 감싸고,
|
||||
* 값 안의 큰따옴표는 두 개("")로 바꾼다. 이게 CSV의 표준 규칙이다.
|
||||
* "간단해 보이는 형식일수록 예외 처리를 빠뜨리기 쉽다."
|
||||
*
|
||||
* 학습 포인트 ② — 엑셀 한글 깨짐 방지.
|
||||
* 맨 앞에 BOM()을 붙인다. 이게 없으면 엑셀이 UTF-8을 못 알아보고 한글이 깨진다.
|
||||
* (컨트롤러에서 UTF-8로 인코딩해 내려보낸다.)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public String exportCsv() {
|
||||
DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
StringBuilder sb = new StringBuilder(""); // BOM
|
||||
|
||||
// 헤더
|
||||
sb.append("이름,회사이메일,트랙,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,가입일\n");
|
||||
|
||||
for (StudentProfile p : allProfiles()) {
|
||||
sb.append(csv(p.getUser().getName())).append(',')
|
||||
.append(csv(p.fullEmail())).append(',')
|
||||
.append(csv("DESIGN".equals(p.getUser().getTrack()) ? "디자인" : "개발")).append(',')
|
||||
.append(csv(p.getBirthDate().toString())).append(',')
|
||||
.append(csv(p.getPhone())).append(',')
|
||||
.append(csv(p.getAddress())).append(',')
|
||||
.append(csv(p.getSchool())).append(',')
|
||||
.append(csv(p.getMajor())).append(',')
|
||||
.append(csv(p.getEmergencyName())).append(',')
|
||||
.append(csv(p.getEmergencyRelation())).append(',')
|
||||
.append(csv(p.getEmergencyPhone())).append(',')
|
||||
.append(csv(df.format(p.getUser().getCreatedAt().atZone(
|
||||
java.time.ZoneId.of("Asia/Seoul")))))
|
||||
.append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** CSV 한 칸 이스케이프: 큰따옴표로 감싸고, 안의 큰따옴표는 두 개로. */
|
||||
private String csv(String value) {
|
||||
String v = value == null ? "" : value;
|
||||
return "\"" + v.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
@ -120,11 +120,7 @@ public class AuthController {
|
||||
@PostMapping("/signup")
|
||||
@ResponseStatus(HttpStatus.CREATED) // 자원(계정)이 새로 생겼으므로 REST 관례대로 201
|
||||
public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
|
||||
User user = authService.signup(
|
||||
signupRequest.username(),
|
||||
signupRequest.password(),
|
||||
signupRequest.name(),
|
||||
signupRequest.track());
|
||||
User user = authService.signup(signupRequest);
|
||||
return SignupResponse.from(user);
|
||||
}
|
||||
|
||||
|
||||
@ -8,15 +8,23 @@ import dev.awesomedev.mirim.service.CodingService;
|
||||
import dev.awesomedev.mirim.service.CourseProgressService;
|
||||
import dev.awesomedev.mirim.service.ProgressService;
|
||||
import dev.awesomedev.mirim.service.QuizService;
|
||||
import dev.awesomedev.mirim.service.StudentProfileService;
|
||||
import dev.awesomedev.mirim.service.SubmissionService;
|
||||
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
|
||||
import dev.awesomedev.mirim.web.dto.FeedbackRequest;
|
||||
import dev.awesomedev.mirim.web.dto.MentorResetPasswordRequest;
|
||||
import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse;
|
||||
import dev.awesomedev.mirim.web.dto.StudentCourseProgressResponse;
|
||||
import dev.awesomedev.mirim.web.dto.StudentProfileResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -39,6 +47,7 @@ public class MentorController {
|
||||
private final QuizService quizService;
|
||||
private final CodingService codingService;
|
||||
private final AuthService authService;
|
||||
private final StudentProfileService studentProfileService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public MentorController(SubmissionService submissionService,
|
||||
@ -47,6 +56,7 @@ public class MentorController {
|
||||
QuizService quizService,
|
||||
CodingService codingService,
|
||||
AuthService authService,
|
||||
StudentProfileService studentProfileService,
|
||||
UserRepository userRepository) {
|
||||
this.submissionService = submissionService;
|
||||
this.courseProgressService = courseProgressService;
|
||||
@ -54,6 +64,7 @@ public class MentorController {
|
||||
this.quizService = quizService;
|
||||
this.codingService = codingService;
|
||||
this.authService = authService;
|
||||
this.studentProfileService = studentProfileService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@ -118,6 +129,32 @@ public class MentorController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** GET /api/mentor/students — 수습생 인적사항 명단 (화면 표시용) */
|
||||
@GetMapping("/students")
|
||||
public List<StudentProfileResponse> students() {
|
||||
return studentProfileService.allProfiles().stream()
|
||||
.map(StudentProfileResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mentor/students/export.csv — 수습생 명단 CSV 다운로드 (경리 담당자용).
|
||||
*
|
||||
* 학습 포인트: JSON API와 달리 "파일 다운로드"는 응답 헤더가 중요하다.
|
||||
* - Content-Type: text/csv → 브라우저가 화면에 그리지 않고 파일로 인식
|
||||
* - Content-Disposition: attachment; filename=... → "다운로드하라, 이 이름으로"
|
||||
* 이 두 헤더가 없으면 브라우저는 CSV를 그냥 화면에 텍스트로 펼쳐 버린다.
|
||||
*/
|
||||
@GetMapping("/students/export.csv")
|
||||
public ResponseEntity<Resource> exportCsv() {
|
||||
byte[] bytes = studentProfileService.exportCsv().getBytes(StandardCharsets.UTF_8);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"mirim-students.csv\"")
|
||||
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
|
||||
.body(new ByteArrayResource(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mentor/users/{id}/reset-password — 멘토가 학생의 비밀번호를 재설정한다.
|
||||
*
|
||||
|
||||
@ -1,17 +1,24 @@
|
||||
package dev.awesomedev.mirim.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Past;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 회원가입 요청 본문 {username, password, name, track}을 담는 DTO다.
|
||||
* 회원가입 요청 본문(로그인 정보 + 인적사항)을 담는 DTO다.
|
||||
*
|
||||
* 학습 포인트: 검증 애노테이션(@NotBlank, @Size, @Pattern)을 필드에 붙여 두면,
|
||||
* 컨트롤러에서 @Valid를 만나는 순간 스프링이 자동으로 검사해 준다.
|
||||
* 검사에 실패하면 컨트롤러 코드가 실행되기도 전에 400 Bad Request가 반환된다.
|
||||
* "검증 규칙은 데이터 옆에 선언한다" — if문을 컨트롤러마다 반복해서 쓰지 않아도 되는 이유다.
|
||||
*
|
||||
* 학습 포인트 ②: 인적사항을 가입 때 한 번에 받는다. 개인정보는 꼭 필요한 최소한만 —
|
||||
* 주민번호·계좌번호는 여기 없다(민감정보는 종이 인사 절차로). StudentProfile 주석 참고.
|
||||
*/
|
||||
public record SignupRequest(
|
||||
|
||||
@ -42,6 +49,43 @@ public record SignupRequest(
|
||||
*/
|
||||
@NotBlank(message = "트랙은 필수입니다.")
|
||||
@Pattern(regexp = "^(DEV|DESIGN)$", message = "트랙은 DEV 또는 DESIGN만 선택할 수 있습니다.")
|
||||
String track
|
||||
String track,
|
||||
|
||||
// ── 회사 이메일 (아이디 부분만. 도메인 @awesomedev.dev는 서버가 붙인다) ──
|
||||
// 학습 포인트: 정규식으로 이메일 아이디에 쓸 수 있는 글자만 허용한다.
|
||||
// 소문자·숫자·점·밑줄·하이픈. 대문자나 한글, @ 같은 건 못 넣는다(도메인은 우리가 붙이니까).
|
||||
@NotBlank(message = "회사 이메일 아이디는 필수입니다.")
|
||||
@Size(min = 2, max = 40, message = "이메일 아이디는 2~40자입니다.")
|
||||
@Pattern(regexp = "^[a-z0-9._-]+$", message = "이메일 아이디는 소문자·숫자·. _ - 만 쓸 수 있어요.")
|
||||
String companyEmailLocal,
|
||||
|
||||
// ── 인적사항 ──
|
||||
@NotNull(message = "생년월일은 필수입니다.")
|
||||
@Past(message = "생년월일은 오늘보다 이전이어야 합니다.")
|
||||
LocalDate birthDate, // 프론트에서 "2007-03-15" 형태 문자열 → Jackson이 LocalDate로 변환
|
||||
|
||||
@NotBlank(message = "연락처는 필수입니다.")
|
||||
@Pattern(regexp = "^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$", message = "휴대폰 번호 형식이 올바르지 않아요.")
|
||||
String phone,
|
||||
|
||||
@NotBlank(message = "주소는 필수입니다.")
|
||||
String address,
|
||||
|
||||
@NotBlank(message = "학교는 필수입니다.")
|
||||
String school,
|
||||
|
||||
@NotBlank(message = "전공(학과)은 필수입니다.")
|
||||
String major,
|
||||
|
||||
// ── 비상 연락처 ──
|
||||
@NotBlank(message = "비상 연락처 이름은 필수입니다.")
|
||||
String emergencyName,
|
||||
|
||||
@NotBlank(message = "비상 연락처 관계는 필수입니다.")
|
||||
String emergencyRelation, // 예: 부, 모, 형제
|
||||
|
||||
@NotBlank(message = "비상 연락처 전화번호는 필수입니다.")
|
||||
@Pattern(regexp = "^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$", message = "비상 연락처 전화번호 형식이 올바르지 않아요.")
|
||||
String emergencyPhone
|
||||
) {
|
||||
}
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
package dev.awesomedev.mirim.web.dto;
|
||||
|
||||
import dev.awesomedev.mirim.domain.StudentProfile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일: 멘토 화면에 보여줄 수습생 인적사항 한 명분.
|
||||
*
|
||||
* 학습 포인트: 엔티티를 그대로 내보내지 않고 DTO로 옮긴다. 이메일은 아이디+도메인을
|
||||
* 합친 완성형(fullEmail)으로 준다 — 화면은 조각이 아니라 완성된 값을 원한다.
|
||||
*/
|
||||
public record StudentProfileResponse(
|
||||
Long userId,
|
||||
String name,
|
||||
String username,
|
||||
String track,
|
||||
String companyEmail,
|
||||
LocalDate birthDate,
|
||||
String phone,
|
||||
String address,
|
||||
String school,
|
||||
String major,
|
||||
String emergencyName,
|
||||
String emergencyRelation,
|
||||
String emergencyPhone
|
||||
) {
|
||||
public static StudentProfileResponse from(StudentProfile p) {
|
||||
return new StudentProfileResponse(
|
||||
p.getUser().getId(),
|
||||
p.getUser().getName(),
|
||||
p.getUser().getUsername(),
|
||||
p.getUser().getTrack(),
|
||||
p.fullEmail(),
|
||||
p.getBirthDate(),
|
||||
p.getPhone(),
|
||||
p.getAddress(),
|
||||
p.getSchool(),
|
||||
p.getMajor(),
|
||||
p.getEmergencyName(),
|
||||
p.getEmergencyRelation(),
|
||||
p.getEmergencyPhone());
|
||||
}
|
||||
}
|
||||
@ -188,6 +188,55 @@ function WeeklyChecklistTable({ overview }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 수습생 인적사항 명단 — 경리 담당자가 CSV로 내려받을 수 있다.
|
||||
// 학습 포인트: 개인정보 화면이라 링크가 아니라 표로, 다운로드는 버튼 하나로 명확히 둔다.
|
||||
function StudentRoster({ students }) {
|
||||
// 학습 포인트: 파일 다운로드는 axios(JSON) 말고 브라우저의 링크 이동으로 처리한다.
|
||||
// withCredentials 링크 이동이면 세션 쿠키도 함께 가서, 서버가 멘토인지 확인할 수 있다.
|
||||
const csvUrl = '/api/mentor/students/export.csv';
|
||||
|
||||
return (
|
||||
<Card style={{ padding: 0, overflowX: 'auto' }}>
|
||||
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<span className="muted" style={{ fontSize: 13 }}>
|
||||
총 {students.length}명 · 개인정보이니 취급에 주의하세요
|
||||
</span>
|
||||
<a href={csvUrl} className="btn btn-primary" style={{ fontSize: 13 }}>
|
||||
⬇ CSV 내려받기 (경리용)
|
||||
</a>
|
||||
</div>
|
||||
<table className="table table-static" style={{ minWidth: 720 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>회사 이메일</th>
|
||||
<th>트랙</th>
|
||||
<th>생년월일</th>
|
||||
<th>연락처</th>
|
||||
<th>학교</th>
|
||||
<th>비상 연락</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map((s) => (
|
||||
<tr key={s.userId}>
|
||||
<td style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{s.name}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{s.companyEmail}</td>
|
||||
<td>{s.track === 'DESIGN' ? '디자인' : '개발'}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{s.birthDate}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{s.phone}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{s.school}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }} className="muted">
|
||||
{s.emergencyName}({s.emergencyRelation}) {s.emergencyPhone}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 학생별 퀴즈 성적 요약 표.
|
||||
// 학습 포인트: 서버는 코스별 최고점 목록만 주고, 응시 수·평균·만점 수 집계는 프론트가 한다.
|
||||
function QuizResultsTable({ quizResults }) {
|
||||
@ -247,6 +296,7 @@ export default function MentorPage() {
|
||||
const [students, setStudents] = useState([]); // 학생별 코스 진도
|
||||
const [checklistOverview, setChecklistOverview] = useState(null); // 주차별 체크리스트 진도
|
||||
const [quizResults, setQuizResults] = useState([]); // 학생별 퀴즈 성적
|
||||
const [roster, setRoster] = useState([]); // 수습생 인적사항 명단
|
||||
const [loadError, setLoadError] = useState(false); // 일부 데이터 로딩 실패 여부
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState(null); // 클릭해서 펼친 제출물 id
|
||||
@ -264,13 +314,15 @@ export default function MentorPage() {
|
||||
client.get('/mentor/course-progress'),
|
||||
client.get('/mentor/checklist-progress'),
|
||||
client.get('/mentor/quiz-results'),
|
||||
client.get('/mentor/students'),
|
||||
])
|
||||
.then(([subsRes, progressRes, checklistRes, quizRes]) => {
|
||||
.then(([subsRes, progressRes, checklistRes, quizRes, rosterRes]) => {
|
||||
if (subsRes.status === 'fulfilled') setSubmissions(subsRes.value.data);
|
||||
if (progressRes.status === 'fulfilled') setStudents(progressRes.value.data);
|
||||
if (checklistRes.status === 'fulfilled') setChecklistOverview(checklistRes.value.data);
|
||||
if (quizRes.status === 'fulfilled') setQuizResults(quizRes.value.data);
|
||||
const anyFailed = [subsRes, progressRes, checklistRes, quizRes].some(
|
||||
if (rosterRes.status === 'fulfilled') setRoster(rosterRes.value.data);
|
||||
const anyFailed = [subsRes, progressRes, checklistRes, quizRes, rosterRes].some(
|
||||
(r) => r.status === 'rejected',
|
||||
);
|
||||
setLoadError(anyFailed);
|
||||
@ -335,8 +387,16 @@ export default function MentorPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── 수습생 명단 (인적사항) ── */}
|
||||
<h2 className="section-title">수습생 명단</h2>
|
||||
{roster.length === 0 ? (
|
||||
<p className="empty">아직 가입한 수습생이 없어요. (학생이 회원가입하면 여기에 표시됩니다)</p>
|
||||
) : (
|
||||
<StudentRoster students={roster} />
|
||||
)}
|
||||
|
||||
{/* ── 학생별 코스 진도 ── */}
|
||||
<h2 className="section-title">학생별 코스 진도</h2>
|
||||
<h2 className="section-title" style={{ marginTop: 24 }}>학생별 코스 진도</h2>
|
||||
{students.length === 0 ? (
|
||||
<p className="empty">아직 학생 계정이 없어요.</p>
|
||||
) : (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// 이 파일이 하는 일: 회원가입 폼. 아이디·비밀번호·이름·트랙을 입력받아
|
||||
// 이 파일이 하는 일: 회원가입 폼. 로그인 정보 + 인적사항(개인정보)을 입력받아
|
||||
// POST /api/auth/signup으로 보내고, 성공하면 로그인 페이지로 안내한다.
|
||||
// 로그인 전에도 접근할 수 있는 페이지라서 Layout(상단 메뉴) 바깥에 렌더링된다.
|
||||
import { useState } from 'react';
|
||||
@ -6,86 +6,90 @@ import { useNavigate, Link, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import client from '../api/client';
|
||||
|
||||
// 트랙 선택지. 값(DEV/DESIGN)은 서버 User 엔티티의 track 컬럼 값과 정확히 같아야 한다.
|
||||
// 학습 포인트: 화면에 보이는 한국어 라벨과 서버에 저장되는 코드값을 분리해 두면
|
||||
// 나중에 라벨 문구를 바꿔도 DB 데이터는 그대로 유효하다.
|
||||
const TRACKS = [
|
||||
{ value: 'DEV', label: '개발' },
|
||||
{ value: 'DESIGN', label: '디자인' },
|
||||
];
|
||||
|
||||
const COMPANY_DOMAIN = 'awesomedev.dev'; // 회사 이메일 도메인은 고정 (백엔드 StudentProfile과 일치)
|
||||
|
||||
// 폼 초기값. 필드가 많아지면 useState를 하나씩 두는 대신 객체 하나로 묶는 편이 관리하기 쉽다.
|
||||
// 학습 포인트: 입력이 3~4개면 useState 여러 개가 낫고, 10개가 넘으면 객체 하나가 낫다.
|
||||
// "규칙"이 아니라 "그때그때 읽기 쉬운 쪽"을 고른다.
|
||||
const EMPTY = {
|
||||
username: '', password: '', passwordConfirm: '', name: '', track: 'DEV',
|
||||
companyEmailLocal: '', birthDate: '', phone: '', address: '', school: '', major: '',
|
||||
emergencyName: '', emergencyRelation: '', emergencyPhone: '',
|
||||
};
|
||||
|
||||
export default function SignupPage() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 폼 입력값들. 입력창 하나마다 useState 하나 — 가장 단순하고 읽기 쉬운 방식.
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [track, setTrack] = useState('DEV');
|
||||
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// 가입 성공 여부. true가 되면 폼 대신 "가입 완료" 안내를 보여준다.
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
// 이미 로그인한 상태라면 가입할 이유가 없으니 대시보드로 보낸다.
|
||||
if (user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// 학습 포인트: 프론트 검증은 편의, 진짜 검증은 서버 — 왜 둘 다 필요한가?
|
||||
// - 프론트 검증: 서버에 요청을 보내기 전에 즉시 "비밀번호가 짧아요"라고 알려줘서
|
||||
// 사용자를 기다리게 하지 않는다. 순수하게 사용자 경험(UX)을 위한 것.
|
||||
// - 서버 검증: 프론트 검증은 브라우저 개발자 도구로 얼마든지 우회할 수 있다.
|
||||
// (예: fetch로 API를 직접 호출하면 이 함수는 아예 실행되지 않는다.)
|
||||
// 그래서 데이터를 실제로 저장하는 서버가 반드시 최종 검문소가 되어야 한다.
|
||||
// 입력 하나가 바뀌면 그 필드만 갈아끼운다. (...이전값 펼치고, 바뀐 것 하나 덮어쓰기)
|
||||
function set(key, value) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
// 학습 포인트: 프론트 검증은 편의(즉시 안내), 진짜 검증은 서버(우회 불가). 둘 다 있어야 한다.
|
||||
function validate() {
|
||||
if (!username.trim()) {
|
||||
return '아이디를 입력해 주세요.';
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return '비밀번호는 8자 이상이어야 해요.';
|
||||
}
|
||||
if (password !== passwordConfirm) {
|
||||
return '비밀번호 확인이 일치하지 않아요.';
|
||||
}
|
||||
if (!name.trim()) {
|
||||
return '이름을 입력해 주세요.';
|
||||
}
|
||||
return ''; // 빈 문자열 = 문제 없음
|
||||
if (!form.username.trim()) return '아이디를 입력해 주세요.';
|
||||
if (form.password.length < 8) return '비밀번호는 8자 이상이어야 해요.';
|
||||
if (form.password !== form.passwordConfirm) return '비밀번호 확인이 일치하지 않아요.';
|
||||
if (!form.name.trim()) return '이름을 입력해 주세요.';
|
||||
if (!/^[a-z0-9._-]{2,40}$/.test(form.companyEmailLocal))
|
||||
return '회사 이메일 아이디는 소문자·숫자·. _ - 만 쓸 수 있어요.';
|
||||
if (!form.birthDate) return '생년월일을 입력해 주세요.';
|
||||
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
|
||||
if (!form.address.trim()) return '주소를 입력해 주세요.';
|
||||
if (!form.school.trim()) return '학교를 입력해 주세요.';
|
||||
if (!form.major.trim()) return '전공을 입력해 주세요.';
|
||||
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
|
||||
if (!form.emergencyRelation.trim()) return '비상 연락처 관계를 입력해 주세요.';
|
||||
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.emergencyPhone))
|
||||
return '비상 연락처 전화번호 형식을 확인해 주세요.';
|
||||
return '';
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
// 브라우저의 기본 폼 전송(페이지 새로고침)을 막는다 — LoginPage와 같은 이유.
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// 서버에 보내기 전에 프론트에서 한 번 거른다.
|
||||
const message = validate();
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await client.post('/auth/signup', {
|
||||
username: username.trim(),
|
||||
password,
|
||||
name: name.trim(),
|
||||
track,
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
name: form.name.trim(),
|
||||
track: form.track,
|
||||
companyEmailLocal: form.companyEmailLocal.trim(),
|
||||
birthDate: form.birthDate, // "2007-03-15" — 백엔드가 LocalDate로 변환
|
||||
phone: form.phone.trim(),
|
||||
address: form.address.trim(),
|
||||
school: form.school.trim(),
|
||||
major: form.major.trim(),
|
||||
emergencyName: form.emergencyName.trim(),
|
||||
emergencyRelation: form.emergencyRelation.trim(),
|
||||
emergencyPhone: form.emergencyPhone.trim(),
|
||||
});
|
||||
// 학습 포인트: 가입 성공 = 로그인 성공이 아니다.
|
||||
// 세션은 아직 없으므로, 안내를 보여주고 로그인 페이지로 보낸다.
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
if (err.response?.status === 409) {
|
||||
// 409 Conflict: 서버가 "이미 있는 아이디"라고 알려준 경우.
|
||||
setError('이미 사용 중인 아이디예요. 다른 아이디를 골라 주세요.');
|
||||
setError(err.response.data?.message || '이미 사용 중인 정보가 있어요.');
|
||||
} else if (err.response?.status === 400) {
|
||||
// 400 Bad Request: 프론트 검증을 통과했어도 서버 기준에 안 맞은 경우.
|
||||
setError(err.response.data?.message || '입력 내용을 다시 확인해 주세요.');
|
||||
} else {
|
||||
setError('가입 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
|
||||
@ -95,114 +99,110 @@ export default function SignupPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 이 페이지 안에서만 쓰는 입력 한 줄 부품 — 필드가 많으니 반복을 줄인다.
|
||||
function Field({ label, k, type = 'text', placeholder, autoComplete }) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label htmlFor={`su-${k}`}>{label}</label>
|
||||
<input
|
||||
id={`su-${k}`}
|
||||
className="input"
|
||||
type={type}
|
||||
value={form[k]}
|
||||
onChange={(e) => set(k, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 380 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 460 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 20 }}>
|
||||
<div className="topnav-logo" style={{ fontSize: 18 }}>
|
||||
AWESOMEDEV <span>수습 플랫폼</span>
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: 14, marginTop: 6 }}>
|
||||
회원가입
|
||||
</p>
|
||||
<p className="muted" style={{ fontSize: 14, marginTop: 6 }}>수습생 회원가입</p>
|
||||
</div>
|
||||
|
||||
{done ? (
|
||||
// 가입이 끝난 뒤에는 폼 대신 안내 화면을 보여준다.
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontWeight: 700, marginBottom: 6 }}>가입 완료!</p>
|
||||
<p style={{ fontWeight: 700, marginBottom: 6 }}>가입 완료! 🎉</p>
|
||||
<p className="muted" style={{ fontSize: 14, marginBottom: 16 }}>
|
||||
이제 만든 계정으로 로그인해 주세요.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => navigate('/login')}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/login')}
|
||||
style={{ width: '100%', justifyContent: 'center' }}>
|
||||
로그인하러 가기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="signup-username">아이디</label>
|
||||
<input
|
||||
id="signup-username"
|
||||
className="input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="signup-password">비밀번호 (8자 이상)</label>
|
||||
<input
|
||||
id="signup-password"
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="signup-password-confirm">비밀번호 확인</label>
|
||||
<input
|
||||
id="signup-password-confirm"
|
||||
className="input"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="signup-name">이름</label>
|
||||
<input
|
||||
id="signup-name"
|
||||
className="input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="section-title" style={{ marginTop: 0 }}>계정</div>
|
||||
<Field label="아이디 (영문·숫자 4자 이상)" k="username" autoComplete="username" />
|
||||
<Field label="비밀번호 (8자 이상)" k="password" type="password" autoComplete="new-password" />
|
||||
<Field label="비밀번호 확인" k="passwordConfirm" type="password" autoComplete="new-password" />
|
||||
|
||||
<div className="field">
|
||||
<label>트랙</label>
|
||||
{/* 학습 포인트: 라디오처럼 "둘 중 하나"를 고르는 UI는
|
||||
기존 탭(.tab) 스타일을 재사용하면 새 CSS 없이 해결된다. */}
|
||||
<div className="tabs" style={{ marginBottom: 0 }}>
|
||||
{TRACKS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button" // 학습 포인트: form 안의 button은 기본이 submit이라, 안 막으면 폼이 전송된다.
|
||||
className={`tab${track === t.value ? ' active' : ''}`}
|
||||
onClick={() => setTrack(t.value)}
|
||||
>
|
||||
<button key={t.value} type="button"
|
||||
className={`tab${form.track === t.value ? ' active' : ''}`}
|
||||
onClick={() => set('track', t.value)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
style={{ width: '100%', justifyContent: 'center', marginTop: 8 }}
|
||||
>
|
||||
|
||||
<div className="section-title">인적사항</div>
|
||||
<Field label="이름" k="name" autoComplete="name" />
|
||||
|
||||
{/* 회사 이메일 — 아이디만 입력하고 도메인은 고정으로 붙여 보여준다 */}
|
||||
<div className="field">
|
||||
<label htmlFor="su-companyEmailLocal">회사 이메일</label>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 6 }}>
|
||||
<input
|
||||
id="su-companyEmailLocal"
|
||||
className="input"
|
||||
value={form.companyEmailLocal}
|
||||
onChange={(e) => set('companyEmailLocal', e.target.value.toLowerCase())}
|
||||
placeholder="hong"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span className="badge badge-primary" style={{ display: 'flex', alignItems: 'center', fontSize: 14 }}>
|
||||
@{COMPANY_DOMAIN}
|
||||
</span>
|
||||
</div>
|
||||
{form.companyEmailLocal && (
|
||||
<p className="muted" style={{ fontSize: 12.5, marginTop: 4 }}>
|
||||
→ {form.companyEmailLocal}@{COMPANY_DOMAIN}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="생년월일" k="birthDate" type="date" />
|
||||
<Field label="연락처" k="phone" placeholder="010-1234-5678" autoComplete="tel" />
|
||||
<Field label="주소" k="address" placeholder="시/군/구 이하" />
|
||||
<Field label="학교" k="school" placeholder="미림마이스터고등학교" />
|
||||
<Field label="전공(학과)" k="major" />
|
||||
|
||||
<div className="section-title">비상 연락처</div>
|
||||
<Field label="이름" k="emergencyName" />
|
||||
<Field label="관계 (예: 부, 모)" k="emergencyRelation" />
|
||||
<Field label="전화번호" k="emergencyPhone" placeholder="010-1234-5678" />
|
||||
|
||||
<div className="tip" style={{ marginTop: 4 }}>
|
||||
여기서 받는 정보는 회사 인사·비상 연락용이에요. 주민등록번호·계좌번호 같은
|
||||
민감정보는 <strong>여기 넣지 않아요</strong> — 근로계약서 작성 때 따로 받습니다.
|
||||
</div>
|
||||
|
||||
{error && <p className="error-text" style={{ marginTop: 10 }}>{error}</p>}
|
||||
<button className="btn btn-primary" type="submit" disabled={submitting}
|
||||
style={{ width: '100%', justifyContent: 'center', marginTop: 12 }}>
|
||||
{submitting ? '가입 중...' : '가입하기'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user