diff --git a/.gitignore b/.gitignore index 1fca2db..75452e2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,10 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ src/main/resources/application-local.yml +src/main/resources/logback-spring.xml *.env .env - +docker-compose.yml uploads ### STS ### .apt_generated diff --git a/build.gradle b/build.gradle index cc446b5..a135a26 100644 --- a/build.gradle +++ b/build.gradle @@ -66,6 +66,8 @@ dependencies { //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' } node { diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/controller/homepage/AgencyRestController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/controller/homepage/AgencyRestController.java index a318251..3a1e585 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/controller/homepage/AgencyRestController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/controller/homepage/AgencyRestController.java @@ -2,6 +2,7 @@ import com.cooperation.project.cooperationcenter.domain.member.dto.AgencyRegion; import com.cooperation.project.cooperationcenter.global.exception.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; @@ -17,6 +18,8 @@ @RequestMapping("/api/v1/agency") @Slf4j public class AgencyRestController { + + @Operation(summary = "유학원 지역 리스트 반환") @GetMapping("/region") public BaseResponse getRegionList(){ List regions = Arrays.stream(AgencyRegion.values()) diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/controller/homepage/AgengyController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/controller/homepage/AgengyController.java index 8f1aa39..0a13ba4 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/controller/homepage/AgengyController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/controller/homepage/AgengyController.java @@ -2,6 +2,7 @@ import com.cooperation.project.cooperationcenter.domain.agency.dto.AgencyRequest; import com.cooperation.project.cooperationcenter.domain.agency.service.homepage.AgencyService; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; @@ -24,6 +25,7 @@ public class AgengyController { private final String agencyPath = "homepage/user/agency"; @RequestMapping("/list") + @Operation(summary = "해당 지역과 키워드 값을 가진 유학원 반환") public String agencyList( Model model, @PageableDefault(size = 12, sort = "createdAt", direction = Sort.Direction.DESC) diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/exception/AgencyHandler.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/exception/AgencyHandler.java new file mode 100644 index 0000000..9cb022b --- /dev/null +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/exception/AgencyHandler.java @@ -0,0 +1,8 @@ +package com.cooperation.project.cooperationcenter.domain.agency.exception; + +import com.cooperation.project.cooperationcenter.global.exception.BaseException; +import com.cooperation.project.cooperationcenter.global.exception.codes.BaseCode; + +public class AgencyHandler extends BaseException { + public AgencyHandler(BaseCode code){super(code);} +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/exception/status/AgencyErrorStatus.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/exception/status/AgencyErrorStatus.java new file mode 100644 index 0000000..759e726 --- /dev/null +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/exception/status/AgencyErrorStatus.java @@ -0,0 +1,28 @@ +package com.cooperation.project.cooperationcenter.domain.agency.exception.status; + +import com.cooperation.project.cooperationcenter.global.exception.codes.BaseCode; +import com.cooperation.project.cooperationcenter.global.exception.codes.reason.Reason; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AgencyErrorStatus implements BaseCode { + + AGENCY_NOT_FOUND(HttpStatus.BAD_REQUEST,"AGENCY-0001","해당 유학원은 가입 되지 않은 상태입니다. 확인 후에 다시 가입해주세요"); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public Reason.ReasonDto getReasonHttpStatus() { + return Reason.ReasonDto.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/model/Agency.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/model/Agency.java index db3c586..4004138 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/model/Agency.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/model/Agency.java @@ -74,9 +74,9 @@ public void removeMember(Member member){ this.member.remove(member); } - public void setShare(){ - this.share = !this.share; - } + public void setShare(boolean share){ + this.share = share; + } public static Agency fromDto( MemberRequest.SignupNewAgencyDto dto diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/repository/AgencyRepository.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/repository/AgencyRepository.java index 769515a..01ed19b 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/repository/AgencyRepository.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/agency/repository/AgencyRepository.java @@ -10,9 +10,10 @@ import java.util.Optional; public interface AgencyRepository extends JpaRepository { - long countByAgencyPicture(FileAttachment file); - Optional findAgencyByAgencyNameAndAgencyEmailAndShare(String name, String email,Boolean share); - List findAgenciesByShare(boolean share); - Page findAgenciesByShare(boolean share, Pageable pageable); - -} + long countByAgencyPicture(FileAttachment file); + Optional findAgencyByAgencyNameAndAgencyEmailAndShare(String name, String email,Boolean share); + Optional findAgencyByAgencyNameAndAgencyEmail(String name, String email); + List findAgenciesByShare(boolean share); + Page findAgenciesByShare(boolean share, Pageable pageable); + +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/file/controller/FileRestController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/file/controller/FileRestController.java index 4cd20f7..b9019a5 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/file/controller/FileRestController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/file/controller/FileRestController.java @@ -2,6 +2,7 @@ import com.cooperation.project.cooperationcenter.domain.file.service.FileService; import com.cooperation.project.cooperationcenter.domain.oss.OssService; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; @@ -27,28 +28,33 @@ public class FileRestController { private final FileService fileService; private final OssService ossService; - - //note 다운로드용 @GetMapping("/{type}/{fileId}") + @Operation(summary = "파일 다운로드") public ResponseEntity downloadFile(@PathVariable String type,@PathVariable String fileId) throws MalformedURLException { log.info("enter file controller"); return fileService.loadFile(fileId,type); } - //note 이미지 뷰용 @GetMapping("/img/{type}/{fileId}") + @Operation(summary = "이미지 뷰") public ResponseEntity viewImage(@PathVariable String type,@PathVariable String fileId) throws IOException { log.info("enter file controller-img"); return fileService.viewFile(fileId,type); } - //note 이미지 뷰용 @GetMapping("/pdf/{type}/{fileId}") + @Operation(summary = "pdf새 탭으로 열기") public ResponseEntity viewPdf(@PathVariable String type, @PathVariable String fileId) throws IOException { log.info("enter file controller-img"); return fileService.viewPdf(fileId,type); } + @Operation( + summary = "학교 이미지 저장", + description = """ + 학교에 사용되는 이미지를 저장하고 URL을 반환. + """ + ) @PostMapping("/{type}") public ResponseEntity saveFile(@PathVariable String type,@RequestParam("file-0") MultipartFile file) throws IOException { log.info("save file"); @@ -56,12 +62,14 @@ public ResponseEntity saveFile(@PathVariable String type,@RequestParam("fi } @GetMapping("/default/agency") + @Operation(summary = "유학원 대체 이미지") public ResponseEntity getAgencyDefaultImage(){ log.info("enter agency"); return fileService.viewDefaultImg("agency"); } @GetMapping("/default/school") + @Operation(summary = "학교 대체 이미지") public ResponseEntity getSchoolDefaultImage(){ return fileService.viewDefaultImg("school"); } diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/file/exception/FileHandler.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/file/exception/FileHandler.java new file mode 100644 index 0000000..b9be191 --- /dev/null +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/file/exception/FileHandler.java @@ -0,0 +1,8 @@ +package com.cooperation.project.cooperationcenter.domain.file.exception; + +import com.cooperation.project.cooperationcenter.global.exception.BaseException; +import com.cooperation.project.cooperationcenter.global.exception.codes.BaseCode; + +public class FileHandler extends BaseException { + public FileHandler(BaseCode code){super(code);} +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/file/exception/status/FileErrorStatus.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/file/exception/status/FileErrorStatus.java new file mode 100644 index 0000000..843800d --- /dev/null +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/file/exception/status/FileErrorStatus.java @@ -0,0 +1,47 @@ +package com.cooperation.project.cooperationcenter.domain.file.exception.status; + +import com.cooperation.project.cooperationcenter.global.exception.codes.BaseCode; +import com.cooperation.project.cooperationcenter.global.exception.codes.reason.Reason; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum FileErrorStatus implements BaseCode { + + // ===== 요청/검증 ===== + FILE_EMPTY(HttpStatus.BAD_REQUEST, "FILE-4001", "업로드할 파일이 존재하지 않습니다."), + FILE_TYPE_INVALID(HttpStatus.BAD_REQUEST, "FILE-4002", "지원하지 않는 파일 타입입니다."), + FILE_TARGET_INVALID(HttpStatus.BAD_REQUEST, "FILE-4003", "파일 대상 타입이 올바르지 않습니다."), + FILE_SIZE_ERROR(HttpStatus.BAD_REQUEST, "FILE-4004", "파일 사이즈가 너무 큽니다."), + + // ===== 조회 ===== + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-4041", "파일을 찾을 수 없습니다."), + FILE_META_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-4042", "파일 메타데이터가 존재하지 않습니다."), + + // ===== 저장 ===== + FILE_SAVE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-5001", "파일 저장에 실패했습니다."), + FILE_UPLOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-5002", "스토리지 업로드에 실패했습니다."), + + // ===== 스토리지 ===== + FILE_STORAGE_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-5003", "스토리지에 파일이 존재하지 않습니다."), + FILE_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-5004", "파일 삭제에 실패했습니다."), + + // ===== URL ===== + FILE_URL_GENERATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-5005", "파일 URL 생성에 실패했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public Reason.ReasonDto getReasonHttpStatus() { + return Reason.ReasonDto.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/file/service/FileService.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/file/service/FileService.java index 7f8786d..35aa3f1 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/file/service/FileService.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/file/service/FileService.java @@ -1,278 +1,274 @@ -package com.cooperation.project.cooperationcenter.domain.file.service; - -import com.aliyun.oss.OSS; -import com.aliyun.oss.model.ObjectMetadata; -import com.cooperation.project.cooperationcenter.domain.file.dto.FileAttachmentDto; -import com.cooperation.project.cooperationcenter.domain.file.model.FileAttachment; -import com.cooperation.project.cooperationcenter.domain.file.model.FileTargetType; - -import com.cooperation.project.cooperationcenter.domain.file.repository.FileAttachmentRepository; -import com.cooperation.project.cooperationcenter.domain.oss.OssService; -import com.cooperation.project.cooperationcenter.domain.school.model.SchoolPost; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.Resource; -import org.springframework.core.io.UrlResource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; -import org.springframework.web.util.UriUtils; - -import java.io.IOException; -import java.io.InputStream; -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@Service -@RequiredArgsConstructor -@Slf4j -public class FileService { - - private final FileAttachmentRepository fileAttachmentRepository; - private final OSS oss; - private final OssService ossService; - @Value("${oss.bucket}") private String bucket; - - public String getPath(FileAttachmentDto request){ - String type = request.type(); - FileTargetType fileType = FileTargetType.fromType(type); - - if(fileType.equals(FileTargetType.MEMBER)) return FileTargetType.MEMBER.getFilePath()+request.memberId(); - else if(fileType.equals(FileTargetType.SCHOOL)) return FileTargetType.SCHOOL.getFilePath()+request.postId(); - else if(fileType.equals(FileTargetType.SURVEY)) return FileTargetType.SURVEY.getFilePath()+request.surveyId(); - else return null; - } - - /** 파일을 로컬에 저장하지 않고 바로 업로드 */ - @Transactional - public FileAttachment saveFile(FileAttachmentDto request) { - MultipartFile file = request.file(); - try (InputStream in = file.getInputStream()) { - String path = getPath(request); - FileTargetType fileType = FileTargetType.fromType(request.type()); - FileAttachment inputFile = saveFileModel(path,file,fileType); - - String key = String.format("%s/%s", - path, inputFile.getStoredName()); // 예: uploads/uuid_name.png - - ObjectMetadata meta = new ObjectMetadata(); - meta.setContentLength(file.getSize()); - if (file.getContentType() != null) meta.setContentType(file.getContentType()); - meta.setHeader("x-oss-server-side-encryption", "AES256"); - // meta.setHeader(OSSHeaders.SERVER_SIDE_ENCRYPTION, "AES256"); - - oss.putObject(bucket, key, in, meta); - return inputFile; - } catch (IOException e) { - throw new RuntimeException("Failed to upload to OSS", e); - } - } - - @Transactional - public FileAttachment saveFileModel(String path, MultipartFile file, FileTargetType type){ - FileAttachment inputFile = FileAttachment.builder() - .path(path) - .storedPath(path) - .file(file) - .filetype(type) - .build(); - - return fileAttachmentRepository.save(inputFile); - } - - public String getKey(FileAttachment file){ - return file.getPath(); - } - - public FileAttachment loadFileAttachment(String fileId,String type){ - try{ - FileTargetType fileType = FileTargetType.fromType(type); - return fileAttachmentRepository.findByFileIdAndFiletype(fileId,fileType).orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND) - ); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public FileAttachment loadFileAttachment(String fileId,FileTargetType type){ - try{ - return fileAttachmentRepository.findByFileIdAndFiletype(fileId,type).orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND) - ); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public ResponseEntity loadFile(String fileId,String type) { - try{ - FileTargetType fileType = FileTargetType.fromType(type); - FileAttachment file = fileAttachmentRepository.findByFileIdAndFiletype(fileId,fileType) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "파일을 찾을 수 없습니다.")); - - URL url = getDownloadUrl(file); - - log.info("ket:{}",file.getPath()); - return ResponseEntity.status(HttpStatus.FOUND) // 302 - .location(URI.create(url.toString())) - .build(); - - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public ResponseEntity viewFile(String fileId,String type){ - try{ - FileTargetType fileType = FileTargetType.fromType(type); - FileAttachment file = fileAttachmentRepository.findByFileIdAndFiletype(fileId,fileType) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "파일을 찾을 수 없습니다.")); - - URL url = getViewUrl(file); - log.info("ket:{}",file.getPath()); - return ResponseEntity.status(HttpStatus.FOUND) // 302 - .location(URI.create(url.toString())) - .build(); - - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public ResponseEntity viewPdf(String fileId,String type){ - try{ - FileTargetType fileType = FileTargetType.fromType(type); - FileAttachment file = fileAttachmentRepository.findByFileIdAndFiletype(fileId,fileType) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "파일을 찾을 수 없습니다.")); - - URL url = getViewUrl(file); - log.info("ket:{}",file.getPath()); - - final String key = file.getPath(); - final String filename = file.getStoredName(); - final String contentType = (file.getContentType() != null) ? file.getContentType() : "application/octet-stream"; - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType(contentType)); // 예: application/pdf - headers.set(HttpHeaders.CONTENT_DISPOSITION, contentDispositionInline(filename)); - headers.setCacheControl("public, max-age=600"); - - StreamingResponseBody body = outputStream -> { - try (InputStream in = openObject(key)) { - in.transferTo(outputStream); - } - }; - - return new ResponseEntity<>(body, headers, HttpStatus.OK); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - private String contentDispositionInline(String filename) { - String enc = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8) - .replace("+", "%20"); - return "inline; filename=\"" + enc + "\"; filename*=UTF-8''" + enc; - } - - public InputStream openObject(String key) { - var obj = oss.getObject(bucket, key); - return obj.getObjectContent(); // 반드시 호출 측에서 close - } - - @Transactional - public void deleteFile(FileAttachment fileAttachment){ - try{ - if (!oss.doesObjectExist(bucket, fileAttachment.getPath())) return; // 혹은 로그만 - oss.deleteObject(bucket, fileAttachment.getPath()); - - fileAttachmentRepository.delete(fileAttachment); - }catch (Exception e){ - log.warn(e.getMessage()); - } - } - - public void deleteFile(List fileAttachments){ - try{ - fileAttachmentRepository.deleteAll(fileAttachments); - }catch (Exception e) { - log.warn(e.getMessage()); - } - } - - public void deleteFileById(String fileId,FileTargetType type){ - try{ - FileAttachment file = loadFileAttachment(fileId,type); - deleteFile(file); - }catch (Exception e) { - log.warn(e.getMessage()); - } - } - - public URL getViewUrl(FileAttachment file){ - return ossService.presignedGetUrl(file.getPath(), 15, false, file.getStoredName(), null); - } - - public URL getViewUrl(String path,String fileName){ - log.info("path:{}, fileName:{}",path,fileName); - return ossService.presignedGetUrl(path, 15, false, null, null); - } - - public URL getDownloadUrl(FileAttachment file){ - return ossService.presignedGetUrl(file.getPath(), 15, true, file.getStoredName(), file.getContentType()); - } - - public ResponseEntity saveSchoolImgAndReturnUrl(String type, MultipartFile file){ - log.info("save image enter..."); - String path = FileTargetType.SCHOOL.getFilePath()+"img"; - FileTargetType fileType = FileTargetType.fromType(type); - - FileAttachment attachment = saveFileModel(path,file,fileType); - - URL url = getViewUrl(attachment); - return ResponseEntity.status(HttpStatus.FOUND) // 302 - .location(URI.create(url.toString())) - .build(); - } - - public ResponseEntity viewDefaultImg(String type){ - try{ - String fileName = null; - if(type.equalsIgnoreCase("agency")) fileName = "agency_default.png"; - else if(type.equalsIgnoreCase("school")) fileName = "school_default.jpg"; - URL url = getViewUrl(fileName,fileName); - log.info("url log:{}",url.toString()); - log.info("url log:{}",fileName); - return ResponseEntity.status(HttpStatus.FOUND) // 302 - .location(URI.create(url.toString())) - .build(); - - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - -} +package com.cooperation.project.cooperationcenter.domain.file.service; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.model.ObjectMetadata; +import com.cooperation.project.cooperationcenter.domain.file.dto.FileAttachmentDto; +import com.cooperation.project.cooperationcenter.domain.file.exception.FileHandler; +import com.cooperation.project.cooperationcenter.domain.file.exception.status.FileErrorStatus; +import com.cooperation.project.cooperationcenter.domain.file.model.FileAttachment; +import com.cooperation.project.cooperationcenter.domain.file.model.FileTargetType; +import com.cooperation.project.cooperationcenter.domain.file.repository.FileAttachmentRepository; +import com.cooperation.project.cooperationcenter.domain.oss.OssService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FileService { + + private final FileAttachmentRepository fileAttachmentRepository; + private final OSS oss; + private final OssService ossService; + @Value("${oss.bucket}") + private String bucket; + + public String getPath(FileAttachmentDto request) { + FileTargetType fileType = resolveFileType(request.type()); + return switch (fileType) { + case MEMBER -> FileTargetType.MEMBER.getFilePath() + request.memberId(); + case SCHOOL -> FileTargetType.SCHOOL.getFilePath() + request.postId(); + case SURVEY -> FileTargetType.SURVEY.getFilePath() + request.surveyId(); + }; + } + + /** 파일을 로컬에 저장하지 않고 바로 업로드 */ + @Transactional + public FileAttachment saveFile(FileAttachmentDto request) { + MultipartFile file = request.file(); + + if (file == null || file.isEmpty()) { + throw new FileHandler(FileErrorStatus.FILE_EMPTY); + } + + FileTargetType fileType = resolveFileType(request.type()); + String path = getPath(request); + + FileAttachment inputFile = saveFileModel(path, file, fileType); + uploadObject(path, file, inputFile.getStoredName()); + return inputFile; + } + + @Transactional + public FileAttachment saveFileModel(String path, MultipartFile file, FileTargetType type) { + FileAttachment inputFile = FileAttachment.builder() + .path(path) + .storedPath(path) + .file(file) + .filetype(type) + .build(); + + return fileAttachmentRepository.save(inputFile); + } + + public String getKey(FileAttachment file) { + return file.getPath(); + } + + public FileAttachment loadFileAttachment(String fileId, String type) { + FileTargetType fileType = resolveFileType(type); + return fileAttachmentRepository.findByFileIdAndFiletype(fileId, fileType) + .orElseThrow(() -> new FileHandler(FileErrorStatus.FILE_META_NOT_FOUND)); + } + + public FileAttachment loadFileAttachment(String fileId, FileTargetType type) { + return fileAttachmentRepository.findByFileIdAndFiletype(fileId, type) + .orElseThrow(() -> new FileHandler(FileErrorStatus.FILE_META_NOT_FOUND)); + } + + public ResponseEntity loadFile(String fileId, String type) { + FileAttachment file = findAttachment(fileId, type); + URL url = getDownloadUrl(file); + + log.info("key:{}", file.getPath()); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url.toString())) + .build(); + } + + public ResponseEntity viewFile(String fileId, String type) { + FileAttachment file = findAttachment(fileId, type); + URL url = getViewUrl(file); + + log.info("key:{}", file.getPath()); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url.toString())) + .build(); + } + + public ResponseEntity viewPdf(String fileId, String type) { + FileAttachment file = findAttachment(fileId, type); + + final String key = file.getPath(); + final String filename = file.getStoredName(); + final String contentType = (file.getContentType() != null) ? file.getContentType() : "application/octet-stream"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(contentType)); + headers.set(HttpHeaders.CONTENT_DISPOSITION, contentDispositionInline(filename)); + headers.setCacheControl("public, max-age=600"); + + StreamingResponseBody body = outputStream -> { + try (InputStream in = openObject(key)) { + in.transferTo(outputStream); + } + }; + + return new ResponseEntity<>(body, headers, HttpStatus.OK); + } + + private String contentDispositionInline(String filename) { + String enc = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8) + .replace("+", "%20"); + return "inline; filename=\"" + enc + "\"; filename*=UTF-8''" + enc; + } + + public InputStream openObject(String key) { + var obj = oss.getObject(bucket, key); + return obj.getObjectContent(); + } + + @Transactional + public void deleteFile(FileAttachment fileAttachment) { + try { + if (fileAttachment == null) { + return; + } + if (oss.doesObjectExist(bucket, fileAttachment.getPath())) { + oss.deleteObject(bucket, fileAttachment.getPath()); + } + fileAttachmentRepository.delete(fileAttachment); + } catch (Exception e) { + log.error("File delete error", e); + throw new FileHandler(FileErrorStatus.FILE_DELETE_ERROR); + } + } + + @Transactional + public void deleteFile(List fileAttachments) { + if (fileAttachments == null || fileAttachments.isEmpty()) { + return; + } + fileAttachments.forEach(this::deleteFile); + } + + @Transactional + public void deleteFileById(String fileId, FileTargetType type) { + FileAttachment file = loadFileAttachment(fileId, type); + deleteFile(file); + } + + public URL getViewUrl(FileAttachment file) { + try { + return ossService.presignedGetUrl(file.getPath(), 15, false, file.getStoredName(), null); + } catch (Exception e) { + log.error("Presigned URL generation failed", e); + throw new FileHandler(FileErrorStatus.FILE_URL_GENERATE_ERROR); + } + } + + public URL getViewUrl(String path, String fileName) { + return getViewUrl(path); + } + + public URL getViewUrl(String path) { + try { + return ossService.presignedGetUrl(path, 15, false, null, null); + } catch (Exception e) { + log.error("Presigned URL generation failed", e); + throw new FileHandler(FileErrorStatus.FILE_URL_GENERATE_ERROR); + } + } + + public URL getDownloadUrl(FileAttachment file) { + try { + return ossService.presignedGetUrl(file.getPath(), 15, true, file.getStoredName(), file.getContentType()); + } catch (Exception e) { + log.error("Presigned Download URL generation failed", e); + throw new FileHandler(FileErrorStatus.FILE_URL_GENERATE_ERROR); + } + } + + public ResponseEntity saveSchoolImgAndReturnUrl(String type, MultipartFile file) { + log.info("save image enter..."); + if (file == null || file.isEmpty()) { + throw new FileHandler(FileErrorStatus.FILE_EMPTY); + } + + String path = FileTargetType.SCHOOL.getFilePath() + "img"; + FileTargetType fileType = resolveFileType(type); + + FileAttachment attachment = saveFileModel(path, file, fileType); + uploadObject(path, file, attachment.getStoredName()); + + URL url = getViewUrl(attachment); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url.toString())) + .build(); + } + + public ResponseEntity viewDefaultImg(String type) { + String fileName = null; + if (type.equalsIgnoreCase("agency")) fileName = "agency_default.png"; + else if (type.equalsIgnoreCase("school")) fileName = "school_default.jpg"; + + if (fileName == null) { + throw new FileHandler(FileErrorStatus.FILE_TARGET_INVALID); + } + + URL url = getViewUrl(fileName); + log.info("url log:{}", url); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url.toString())) + .build(); + } + + private FileTargetType resolveFileType(String type) { + try { + return FileTargetType.fromType(type); + } catch (Exception e) { + throw new FileHandler(FileErrorStatus.FILE_TARGET_INVALID); + } + } + + private FileAttachment findAttachment(String fileId, String type) { + FileTargetType fileType = resolveFileType(type); + return loadFileAttachment(fileId, fileType); + } + + private void uploadObject(String path, MultipartFile file, String storedName) { + String key = String.format("%s/%s", path, storedName); + + try (InputStream in = file.getInputStream()) { + ObjectMetadata meta = new ObjectMetadata(); + meta.setContentLength(file.getSize()); + if (file.getContentType() != null) { + meta.setContentType(file.getContentType()); + } + meta.setHeader("x-oss-server-side-encryption", "AES256"); + + oss.putObject(bucket, key, in, meta); + } catch (IOException e) { + log.error("File upload IO error", e); + throw new FileHandler(FileErrorStatus.FILE_UPLOAD_ERROR); + } catch (Exception e) { + log.error("File save error", e); + throw new FileHandler(FileErrorStatus.FILE_SAVE_ERROR); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/home/controller/HomeController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/home/controller/HomeController.java index 8282770..fd10df8 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/home/controller/HomeController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/home/controller/HomeController.java @@ -21,6 +21,7 @@ public class HomeController { @RequestMapping({"/", "/home"}) public String home(Model model, HttpServletRequest request) { + log.info("home controller진입"); model.addAttribute("agencyDto", agencyService.getAgencyListForHome()); model.addAttribute("schoolDto", schoolFindService.loadAllSchoolByHomeDto()); return "homepage/user/index"; diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/adminpage/MemberAdminRestController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/adminpage/MemberAdminRestController.java index cf18725..1110480 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/adminpage/MemberAdminRestController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/adminpage/MemberAdminRestController.java @@ -7,6 +7,7 @@ import com.cooperation.project.cooperationcenter.domain.member.service.MemberService; import com.cooperation.project.cooperationcenter.global.exception.BaseResponse; import com.cooperation.project.cooperationcenter.global.exception.codes.ErrorCode; +import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -27,6 +28,12 @@ public class MemberAdminRestController { private final MemberService memberService; private final LoginLogService loginLogService; + @Operation( + summary = "관리자 로그인", + description = """ + 관리자 계정으로 로그인합니다. + """ + ) @PostMapping("/login") public BaseResponse login(@RequestBody MemberRequest.LoginDto request, HttpServletResponse response) throws Exception{ log.info("id:{}, pw:{}",request.email(),request.password()); @@ -34,6 +41,12 @@ public BaseResponse login(@RequestBody MemberRequest.LoginDto request, HttpSe return BaseResponse.onSuccess("success"); } + @Operation( + summary = "관리자 로그인 기록 조회", + description = """ + 관리자 로그인 이력을 페이지네이션 형태로 조회합니다. + """ + ) @GetMapping("/login/log") public BaseResponse loginPage( @PageableDefault(size = 4, sort = "createdAt", direction = Sort.Direction.DESC) @@ -42,6 +55,12 @@ public BaseResponse loginPage( return BaseResponse.onSuccess(loginLogService.getAllLogDtoByPage(pageable)); } + @Operation( + summary = "회원 가입 승인", + description = """ + 관리자 권한으로 회원 가입을 승인합니다. + """ + ) @PostMapping("/accept/{memberEmail}") public BaseResponse acceptMember(@PathVariable String memberEmail){ log.info("email:{}",memberEmail); @@ -53,6 +72,12 @@ public BaseResponse acceptMember(@PathVariable String memberEmail){ } } + @Operation( + summary = "회원 승인 대기 처리", + description = """ + 특정 회원을 승인 대기 상태로 변경합니다. + """ + ) @PostMapping("/pending/{memberEmail}") public BaseResponse pendingMember(@PathVariable String memberEmail){ log.info("email:{}",memberEmail); @@ -64,6 +89,12 @@ public BaseResponse pendingMember(@PathVariable String memberEmail){ } } + @Operation( + summary = "회원 상세 조회", + description = """ + 이메일을 기준으로 회원 상세 정보를 조회합니다. + """ + ) @GetMapping("/detail/{memberEmail}") public BaseResponse detailMember(@PathVariable String memberEmail){ log.info("email:{}",memberEmail); diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberAddressController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberAddressController.java index 3221549..97fd823 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberAddressController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberAddressController.java @@ -2,6 +2,7 @@ import com.cooperation.project.cooperationcenter.domain.member.service.MemberAddressService; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; @@ -17,6 +18,13 @@ public class MemberAddressController { private final MemberAddressService memberAddressService; + @Operation( + summary = "주소 자동완성 검색", + description = """ + 키워드를 기반으로 주소 자동완성 검색 결과를 제공합니다. + 외부 주소 API를 통해 데이터를 조회합니다. + """ + ) @CrossOrigin(origins = "*") // 필요시 특정 도메인으로 제한 가능 @GetMapping("/address") public ResponseEntity suggest(@RequestParam String keyword) { diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberProfileRestController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberProfileRestController.java index 46fbcab..2512db2 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberProfileRestController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberProfileRestController.java @@ -2,12 +2,11 @@ import com.cooperation.project.cooperationcenter.domain.member.dto.MemberDetails; import com.cooperation.project.cooperationcenter.domain.member.dto.Profile; -import com.cooperation.project.cooperationcenter.domain.member.dto.UpdatePasswordDto; import com.cooperation.project.cooperationcenter.domain.member.service.MemberService; import com.cooperation.project.cooperationcenter.domain.member.service.ProfileService; import com.cooperation.project.cooperationcenter.global.exception.BaseException; import com.cooperation.project.cooperationcenter.global.exception.BaseResponse; -import com.cooperation.project.cooperationcenter.global.exception.codes.ErrorCode; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -23,6 +22,12 @@ public class MemberProfileRestController { public final ProfileService profileService; private final MemberService memberService; + @Operation( + summary = "회원 기본 정보 수정", + description = """ + 회원 이름, 연락처, 주소 등 기본 정보를 수정합니다. + """ + ) @PatchMapping("/member") public BaseResponse updateMemberInfo(@RequestBody Profile.MemberDto request, @AuthenticationPrincipal MemberDetails memberDetails){ log.info("request:{}",request.toString()); @@ -38,6 +43,12 @@ public BaseResponse updateMemberInfo(@RequestBody Profile.MemberDto request, } } + @Operation( + summary = "유학원 정보 수정", + description = """ + 유학원 이름, 지역, 연락처 정보를 수정합니다. + """ + ) @PatchMapping("/agency") public BaseResponse updateAgencyInfo(@RequestBody Profile.AgencyDto request, @AuthenticationPrincipal MemberDetails memberDetails){ log.info("request:{}",request.toString()); @@ -53,13 +64,19 @@ public BaseResponse updateAgencyInfo(@RequestBody Profile.AgencyDto request, } } + @Operation( + summary = "사업자 등록증 수정", + description = """ + 사업자 등록증 파일을 업로드하여 변경합니다. + """ + ) @PatchMapping("/businessCert") public BaseResponse updateBusinessCertificate( @RequestPart(name = "businessCertificate", required = false) MultipartFile file , @AuthenticationPrincipal MemberDetails memberDetails ){ try{ - profileService.updateBussinessCert(file,memberDetails); + profileService.updateBusinessCert(file,memberDetails); return BaseResponse.onSuccess("success"); }catch (BaseException e){ log.warn(e.getCode().toString()); @@ -70,6 +87,12 @@ public BaseResponse updateBusinessCertificate( } } + @Operation( + summary = "유학원 프로필 이미지 수정", + description = """ + 유학원 프로필 이미지를 업로드 및 변경합니다. + """ + ) @PatchMapping("/agencyPicture") public BaseResponse updateAgencyPicture( @RequestPart(name = "agencyPicture", required = false) MultipartFile file diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberRestController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberRestController.java index 04402be..e9b321f 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberRestController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/controller/homepage/MemberRestController.java @@ -8,6 +8,7 @@ import com.cooperation.project.cooperationcenter.global.exception.BaseResponse; import com.cooperation.project.cooperationcenter.global.exception.codes.ErrorCode; import com.fasterxml.jackson.core.JsonProcessingException; +import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -28,6 +29,13 @@ public class MemberRestController { private static final Logger log = LoggerFactory.getLogger(MemberRestController.class); private final MemberService memberService; + @Operation( + summary = "회원가입", + description = """ + 일반 회원 및 유학원 회원 가입을 처리합니다. + 회원 정보와 유학원 정보, 선택적으로 첨부 파일을 함께 등록합니다. + """ + ) @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public BaseResponse signup( @RequestPart("memberData") String memberData, @@ -46,6 +54,12 @@ public BaseResponse signup( } } + @Operation( + summary = "ID중복확인", + description = """ + 회원가입시 가입하려는 사용자의 ID 중복 여부를 판단 + """ + ) @GetMapping("/check-id") public BaseResponse checkDuplicateId(@RequestParam String username) { try{ @@ -58,6 +72,13 @@ public BaseResponse checkDuplicateId(@RequestParam String username) { } } + @Operation( + summary = "로그인", + description = """ + 이메일과 비밀번호를 이용해 로그인을 수행합니다. + 인증 성공 시 AccessToken 및 RefreshToken을 반환합니다. + """ + ) @PostMapping("/login") public BaseResponse login(@RequestBody MemberRequest.LoginDto requestDto, HttpServletResponse response,HttpServletRequest request){ try{ @@ -73,6 +94,12 @@ public BaseResponse login(@RequestBody MemberRequest.LoginDto requestDto, Htt } + @Operation( + summary = "로그아웃", + description = """ + 현재 로그인된 사용자의 RefreshToken을 만료 처리합니다. + """ + ) @PostMapping("/logout") public BaseResponse userLogout(HttpServletRequest request ,HttpServletResponse response){ try { @@ -86,6 +113,12 @@ public BaseResponse userLogout(HttpServletRequest request ,HttpServletRespons } } + @Operation( + summary = "토큰 재발급", + description = """ + 만료된 AccessToken을 RefreshToken을 통해 재발급합니다. + """ + ) @PostMapping("/refresh") public BaseResponse refreshToken( HttpServletRequest request, @@ -96,6 +129,12 @@ public BaseResponse refreshToken( } //fixme 확인하면 비밀번호 수정하는 HTML이어야함. 수정 필요 + @Operation( + summary = "비밀번호 재설정 이메일 전송", + description = """ + 비밀번호 재설정을 위한 인증 이메일을 발송합니다. + """ + ) @PostMapping("/reset/email") public BaseResponse sendPasswordResetEmail(@RequestBody UpdatePasswordDto.CheckEmailDto request) throws Exception { log.info("request:{}",request.toString()); @@ -103,6 +142,12 @@ public BaseResponse sendPasswordResetEmail(@RequestBody UpdatePasswordDto.Che return BaseResponse.onSuccess("success"); } + @Operation( + summary = "비밀번호 재설정", + description = """ + 이메일로 전달받은 인증 토큰을 이용해 비밀번호를 재설정합니다. + """ + ) @PostMapping("/reset/password") public BaseResponse updatePassword(@RequestBody UpdatePasswordDto.PasswordCheckDto request) throws Exception { log.info("request:{}",request.toString()); diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/exception/MemberHandler.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/exception/MemberHandler.java new file mode 100644 index 0000000..ca7e69a --- /dev/null +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/exception/MemberHandler.java @@ -0,0 +1,8 @@ +package com.cooperation.project.cooperationcenter.domain.member.exception; + +import com.cooperation.project.cooperationcenter.global.exception.BaseException; +import com.cooperation.project.cooperationcenter.global.exception.codes.BaseCode; + +public class MemberHandler extends BaseException { + public MemberHandler(BaseCode code){super(code);} +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/exception/status/MemberErrorStatus.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/exception/status/MemberErrorStatus.java new file mode 100644 index 0000000..7114ca1 --- /dev/null +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/exception/status/MemberErrorStatus.java @@ -0,0 +1,43 @@ +package com.cooperation.project.cooperationcenter.domain.member.exception.status; + +import com.cooperation.project.cooperationcenter.global.exception.codes.BaseCode; +import com.cooperation.project.cooperationcenter.global.exception.codes.reason.Reason; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MemberErrorStatus implements BaseCode { + // ===== 조회 ===== + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-4041", "존재하지 않는 회원입니다."), + EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "LOGIN-0000", "이메일이 잘못됨"), + PASSWORD_ERROR(HttpStatus.BAD_REQUEST, "LOGIN-0001", "잘못된 비밀번호입니다."), + MEMBER_AGENCY_NOT_FOUND(HttpStatus.NOT_FOUND,"MEMBER-4043","소속된 기관 정보가 존재하지 않습니다."), + // ===== 생성 / 중복 ===== + MEMBER_ALREADY_EXIST(HttpStatus.CONFLICT, "MEMBER-4091", "이미 존재하는 회원입니다."), + MEMBER_ALREADY_ACCEPTED_EMAIL(HttpStatus.CONFLICT, "MEMBER-4092", "해당 이메일로 승인된 계정이 이미 존재합니다."), + + // ===== 상태 ===== + MEMBER_NOT_ACCEPTED(HttpStatus.FORBIDDEN, "MEMBER-4031", "아직 계정이 활성화되지 않았습니다."), + MEMBER_STATUS_INVALID(HttpStatus.FORBIDDEN,"MEMBER-4033","비활성화된 계정입니다."), + // ===== 권한 ===== + MEMBER_NOT_ADMIN(HttpStatus.FORBIDDEN, "MEMBER-4032", "관리자 권한이 필요합니다."), + + // ===== 서버 ===== + MEMBER_SAVE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "MEMBER-5001", "회원 저장 중 오류가 발생했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + public Reason.ReasonDto getReasonHttpStatus() { + return Reason.ReasonDto.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } + +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/repository/MemberRepository.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/repository/MemberRepository.java index d03ae16..78387a4 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/repository/MemberRepository.java @@ -7,10 +7,11 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import java.time.LocalDateTime; -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; +import java.time.LocalDateTime; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import com.cooperation.project.cooperationcenter.domain.agency.model.Agency; public interface MemberRepository extends JpaRepository,MemberRepositoryCustom { Optional findMemberByEmail(String email); @@ -21,9 +22,10 @@ public interface MemberRepository extends JpaRepository,MemberRepos Member findMemberByEmailAndMemberName(String email, String name); long count(); - long countByStatus(UserStatus status); - long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); - - long countByApprovedDateGreaterThanEqualAndApprovedDateLessThan(LocalDate start,LocalDate end); + long countByStatus(UserStatus status); + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + boolean existsByAgencyAndStatus(Agency agency, UserStatus status); + + long countByApprovedDateGreaterThanEqualAndApprovedDateLessThan(LocalDate start,LocalDate end); } diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/MemberDetailsService.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/MemberDetailsService.java index 926dfaa..5826e7f 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/MemberDetailsService.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/MemberDetailsService.java @@ -2,6 +2,8 @@ import com.cooperation.project.cooperationcenter.domain.member.dto.MemberAuthContext; import com.cooperation.project.cooperationcenter.domain.member.dto.MemberDetails; +import com.cooperation.project.cooperationcenter.domain.member.exception.MemberHandler; +import com.cooperation.project.cooperationcenter.domain.member.exception.status.MemberErrorStatus; import com.cooperation.project.cooperationcenter.domain.member.model.Member; import com.cooperation.project.cooperationcenter.domain.member.repository.MemberRepository; import com.cooperation.project.cooperationcenter.global.exception.BaseException; @@ -17,20 +19,29 @@ @Service @Slf4j public class MemberDetailsService implements UserDetailsService { + private final MemberRepository memberRepository; @Override public MemberDetails loadUserByUsername(String username) throws UsernameNotFoundException { + if (username == null || username.isBlank()) { + log.warn("[AUTH] username is null or blank"); + throw new UsernameNotFoundException("Invalid username"); + } + + Member member = memberRepository.findMemberByEmail(username) + .orElseThrow(() -> { + log.info("[AUTH] member not found: {}", username); + return new UsernameNotFoundException("Member not found"); + }); - Member member = memberRepository.findMemberByEmail(username).orElseThrow( - () -> new BaseException(ErrorCode.MEMBER_NOT_FOUND)); - log.info("loadUserByUsername : {}",member.getEmail()); - if(member== null){ - log.info("[loadUserByUsername] username:{}, {}", username, ErrorCode.MEMBER_NOT_FOUND); + if (!member.isAccept()) { + log.info("[AUTH] member not accepted: {}", username); + throw new BaseException(MemberErrorStatus.MEMBER_NOT_ACCEPTED); } + MemberAuthContext ctx = MemberAuthContext.of(member); - log.info("ctx생성"); return new MemberDetails(ctx); } } \ No newline at end of file diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/MemberService.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/MemberService.java index da30691..6350aea 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/MemberService.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/MemberService.java @@ -1,14 +1,20 @@ package com.cooperation.project.cooperationcenter.domain.member.service; +import com.cooperation.project.cooperationcenter.domain.agency.exception.AgencyHandler; +import com.cooperation.project.cooperationcenter.domain.agency.exception.status.AgencyErrorStatus; import com.cooperation.project.cooperationcenter.domain.agency.model.Agency; import com.cooperation.project.cooperationcenter.domain.agency.repository.AgencyRepository; import com.cooperation.project.cooperationcenter.domain.file.dto.FileAttachmentDto; +import com.cooperation.project.cooperationcenter.domain.file.exception.FileHandler; +import com.cooperation.project.cooperationcenter.domain.file.exception.status.FileErrorStatus; import com.cooperation.project.cooperationcenter.domain.file.model.FileAttachment; import com.cooperation.project.cooperationcenter.domain.file.service.FileService; import com.cooperation.project.cooperationcenter.domain.member.dto.MemberRequest; import com.cooperation.project.cooperationcenter.domain.member.dto.MemberResponse; import com.cooperation.project.cooperationcenter.domain.member.dto.UpdatePasswordDto; +import com.cooperation.project.cooperationcenter.domain.member.exception.MemberHandler; +import com.cooperation.project.cooperationcenter.domain.member.exception.status.MemberErrorStatus; import com.cooperation.project.cooperationcenter.domain.member.model.Member; import com.cooperation.project.cooperationcenter.domain.member.model.PasswordResetToken; import com.cooperation.project.cooperationcenter.domain.member.model.UserStatus; @@ -83,35 +89,35 @@ public void signup(String memberData,String agencyData, MultipartFile agencyPict agency = checkAgency(agencyDto); }else{ MemberRequest.SignupNewAgencyDto agencyDto = MemberRequest.SignupNewAgencyDto.from(agencyData); - agency = makeAgency(agencyDto); - - FileAttachment file1 = (agencyPicture==null) ? null : fileService.saveFile(new FileAttachmentDto(agencyPicture,"member",null,uuid,null)); - FileAttachment file2 = (businessCertificate==null) ? null : fileService.saveFile(new FileAttachmentDto(businessCertificate,"member", null,uuid,null)); - - agency.updateFiles(file1,file2); + try{ + FileAttachment file1 = (agencyPicture==null) ? null : fileService.saveFile(new FileAttachmentDto(agencyPicture,"member",null,uuid,null)); + FileAttachment file2 = (businessCertificate==null) ? null : fileService.saveFile(new FileAttachmentDto(businessCertificate,"member", null,uuid,null)); + agency = makeAgency(agencyDto,file1,file2); + }catch (Exception e){ + throw new FileHandler(FileErrorStatus.FILE_SAVE_ERROR); + } } - Member member = Member.fromDto(request.withEncodedPassword(encodedPassword),agency,uuid); agency.addMember(memberRepository.save(member)); } - private Agency checkAgency(MemberRequest.SignupExistingAgencyDto agencyDto){ - return agencyRepository.findAgencyByAgencyNameAndAgencyEmailAndShare(agencyDto.agencyName(),agencyDto.agencyEmail(),true).orElseThrow( - () -> new BaseException(ErrorCode.AGENCY_NOT_FOUND) - ); - } + private Agency checkAgency(MemberRequest.SignupExistingAgencyDto agencyDto){ + return agencyRepository.findAgencyByAgencyNameAndAgencyEmail(agencyDto.agencyName(),agencyDto.agencyEmail()).orElseThrow( + () -> new AgencyHandler(AgencyErrorStatus.AGENCY_NOT_FOUND) + ); + } - private Agency makeAgency(MemberRequest.SignupNewAgencyDto agencyDto){ - return agencyRepository.save( - Agency.fromDto(agencyDto) - ); + private Agency makeAgency(MemberRequest.SignupNewAgencyDto agencyDto,FileAttachment file1,FileAttachment file2){ + Agency agency = Agency.fromDto(agencyDto); + agency.updateFiles(file1,file2); + return agencyRepository.save(agency); } public void login(MemberRequest.LoginDto requestDto,HttpServletResponse response,HttpServletRequest request){ Member member = memberRepository.findMemberByEmail(requestDto.email()) - .orElseThrow(() -> new BaseException(ErrorCode.EMAIL_NOT_FOUND)); + .orElseThrow(() -> new MemberHandler(MemberErrorStatus.EMAIL_NOT_FOUND)); checkLogin(requestDto,member); //성공시 cookie TokenResponse tokenResponse = getTokenResponse(response,member); @@ -125,12 +131,12 @@ public void login(MemberRequest.LoginDto requestDto,HttpServletResponse response public void checkLogin(MemberRequest.LoginDto request, Member member){ if (!passwordEncoder.matches(request.password(), member.getPassword())) { - throw new BaseException(ErrorCode.PASSWORD_ERROR); + throw new MemberHandler(MemberErrorStatus.PASSWORD_ERROR); } if(!member.isAccept()){ log.warn("아직 승인되지 않은 아이디임."); - throw new BaseException(ErrorCode.MEMBER_NOT_ACCEPTED); + throw new MemberHandler(MemberErrorStatus.MEMBER_NOT_ACCEPTED); } } @@ -143,7 +149,7 @@ public Member createMember(MemberRequest.SignupMemberDto request){ return member; }catch (Exception e){ log.warn("member create error"); - throw new BaseException(ErrorCode.MEMBER_SAVE_ERROR); + throw new MemberHandler(MemberErrorStatus.MEMBER_SAVE_ERROR); } } @@ -191,16 +197,8 @@ public BaseResponse updateRefreshToken(HttpServletRequest request, HttpServle } public Member getMember(String email){ - try { - return memberRepository.findMemberByEmail(email) - .orElseThrow(() -> new BaseException(ErrorCode.MEMBER_NOT_FOUND)); - } catch (BaseException e){ - log.warn("멤버 조회 실패: {}", e.getMessage()); - return null; - } catch (Exception e){ - log.error("알 수 없는 에러 발생: {}", e.getMessage(), e); - return null; - } + return memberRepository.findMemberByEmail(email) + .orElseThrow(() -> new MemberHandler(MemberErrorStatus.MEMBER_NOT_FOUND)); } public boolean isUsernameTaken(String username){ @@ -264,8 +262,8 @@ public void sendEmail(UpdatePasswordDto.CheckEmailDto dto) throws Exception { */ public void adminLogin(MemberRequest.LoginDto request,HttpServletResponse response){ Member member = memberRepository.findMemberByEmail(request.email()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 이메일입니다.")); - if(!member.getRole().isAdmin()) throw new BaseException(ErrorCode.MEMBER_NOT_ADMIN); + .orElseThrow(() -> new MemberHandler(MemberErrorStatus.MEMBER_NOT_FOUND)); + if(!member.getRole().isAdmin()) throw new MemberHandler(MemberErrorStatus.MEMBER_NOT_ADMIN); checkLogin(request,member); //성공시 cookie TokenResponse tokenResponse = getTokenResponse(response,member); @@ -274,49 +272,45 @@ public void adminLogin(MemberRequest.LoginDto request,HttpServletResponse respon } @Transactional - public void acceptedMember(String email){ - Member member = memberRepository.findMemberByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 이메일입니다.")); + public void acceptedMember(String email){ + Member member = memberRepository.findMemberByEmail(email) + .orElseThrow(() -> new MemberHandler(MemberErrorStatus.MEMBER_NOT_FOUND)); + boolean duplicated = memberRepository.countMemberByEmailAndStatus(email,UserStatus.APPROVED)>0; - if(duplicated) throw new BaseException( - ErrorCode.MEMBER_ALREADY_ACCEPTED_EMAIL + if(duplicated) throw new MemberHandler( + MemberErrorStatus.MEMBER_ALREADY_ACCEPTED_EMAIL ); - member.accept(); - Agency agency = member.getAgency(); - if(!agency.isShare()) { - agency.setShare(); - agencyRepository.save(agency); - member.setAgency(agency); - } - memberRepository.save(member); - } + member.accept(); + Agency agency = member.getAgency(); + + updateAgencyShareByApprovedMembers(agency); + memberRepository.save(member); + } @Transactional - public void pendingMember(String email){ - Member member = memberRepository.findMemberByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 이메일입니다.")); - member.pending(); - Agency agency = member.getAgency(); - agency.removeMember(member); - if(agency.getMember().isEmpty() && agency.isShare()){ - agency.setShare(); - agencyRepository.save(agency); - } - memberRepository.save(member); - } + public void pendingMember(String email){ + Member member = memberRepository.findMemberByEmail(email) + .orElseThrow(() -> new MemberHandler(MemberErrorStatus.MEMBER_NOT_FOUND)); + member.pending(); + Agency agency = member.getAgency(); + updateAgencyShareByApprovedMembers(agency); + memberRepository.save(member); + } + + private void updateAgencyShareByApprovedMembers(Agency agency) { + boolean hasApprovedMember = memberRepository.existsByAgencyAndStatus(agency, UserStatus.APPROVED); + agency.setShare(hasApprovedMember); + agencyRepository.save(agency); + } public MemberResponse.DetailDto detailMember(String email){ Member member = memberRepository.findMemberByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 이메일입니다.")); + .orElseThrow(() -> new MemberHandler(MemberErrorStatus.MEMBER_NOT_FOUND)); return MemberResponse.DetailDto.from(member); } public List getPendingList(){ List member = memberRepository.findTop4ByApprovalSignupFalseOrderByCreatedAtDesc(); - if(member == null){ - log.warn("가입 승인 대기중인 멤버가 없음."); - return null; - } return MemberResponse.PendingDto.from(member); } diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/ProfileService.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/ProfileService.java index b2a0aca..8b0551c 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/ProfileService.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/member/service/ProfileService.java @@ -3,18 +3,19 @@ import com.cooperation.project.cooperationcenter.domain.agency.model.Agency; import com.cooperation.project.cooperationcenter.domain.agency.repository.AgencyRepository; import com.cooperation.project.cooperationcenter.domain.file.dto.FileAttachmentDto; +import com.cooperation.project.cooperationcenter.domain.file.exception.FileHandler; +import com.cooperation.project.cooperationcenter.domain.file.exception.status.FileErrorStatus; import com.cooperation.project.cooperationcenter.domain.file.model.FileAttachment; import com.cooperation.project.cooperationcenter.domain.file.model.FileTargetType; import com.cooperation.project.cooperationcenter.domain.file.service.FileService; import com.cooperation.project.cooperationcenter.domain.member.dto.MemberDetails; import com.cooperation.project.cooperationcenter.domain.member.dto.Profile; +import com.cooperation.project.cooperationcenter.domain.member.exception.MemberHandler; +import com.cooperation.project.cooperationcenter.domain.member.exception.status.MemberErrorStatus; import com.cooperation.project.cooperationcenter.domain.member.model.Member; import com.cooperation.project.cooperationcenter.domain.member.repository.MemberRepository; import com.cooperation.project.cooperationcenter.domain.survey.model.SurveyLog; -import com.cooperation.project.cooperationcenter.domain.survey.repository.SurveyLogRepository; import com.cooperation.project.cooperationcenter.domain.survey.service.homepage.SurveyFindService; -import com.cooperation.project.cooperationcenter.global.exception.BaseException; -import com.cooperation.project.cooperationcenter.global.exception.codes.ErrorCode; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,8 +26,6 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import java.util.List; -import java.util.Objects; import java.util.UUID; @Service @@ -41,14 +40,21 @@ public class ProfileService { public Member getMember(String email){ return memberRepository.findMemberByEmail(email) - .orElseThrow(() -> new BaseException(ErrorCode.MEMBER_NOT_FOUND)); + .orElseThrow(() -> new MemberHandler(MemberErrorStatus.MEMBER_NOT_FOUND)); } public Profile.ProfileDto getProfileDto(MemberDetails memberDetails, Pageable pageable){ + Member member = getMember(memberDetails.getUsername()); Agency agency = member.getAgency(); + + if (agency == null) { + throw new MemberHandler(MemberErrorStatus.MEMBER_AGENCY_NOT_FOUND); + } + + FileAttachment business = agency.getBusinessPicture(); log.info("memberName:{}",memberDetails.getUsername()); - log.info("member File:{}",agency.getBusinessPicture().toString()); + log.info("business file exists: {}", business != null); Page logs = surveyFindService.getSurveyLogs(member,pageable); @@ -65,6 +71,9 @@ public Profile.ProfileDto getProfileDto(MemberDetails memberDetails, Pageable pa @Transactional public void updateMember(Profile.MemberDto request,MemberDetails memberDetails){ Member member = getMember(memberDetails.getUsername()); + if (!member.isAccept()) { + throw new MemberHandler(MemberErrorStatus.MEMBER_NOT_ACCEPTED); + } member.updateMember(request); memberRepository.save(member); } @@ -72,29 +81,35 @@ public void updateMember(Profile.MemberDto request,MemberDetails memberDetails){ @Transactional public void updateAgency(Profile.AgencyDto request,MemberDetails memberDetails){ Member member = getMember(memberDetails.getUsername()); + if (!member.isAccept()) { + throw new MemberHandler(MemberErrorStatus.MEMBER_NOT_ACCEPTED); + } Agency agency = member.getAgency(); - agency.updateAgency(request); agencyRepository.save(agency); } @Transactional - public void updateBussinessCert(MultipartFile file,MemberDetails memberDetails){ + public void updateBusinessCert(MultipartFile file, MemberDetails memberDetails){ Member member = getMember(memberDetails.getUsername()); Agency agency = member.getAgency(); - // 옛 엔티티를 건드리지 않음 (프록시 초기화 금지) - String uuid = UUID.randomUUID().toString(); - FileAttachment fresh = fileService.saveFile(new FileAttachmentDto(file, "member", null, uuid, null)); + requireFile(file); FileAttachment old = agency.getBusinessPicture(); - String oldFileId = old.getFileId(); - FileTargetType oldType = old.getFiletype(); + String uuid = UUID.randomUUID().toString(); + FileAttachment fresh = fileService.saveFile(new FileAttachmentDto(file, "member", null, uuid, null)); agency.updateBusinessCertificate(fresh); agencyRepository.saveAndFlush(agency); // FK 교체 먼저 확정 - fileService.deleteFileById(oldFileId,oldType); + if (old != null) { + try { + fileService.deleteFileById(old.getFileId(), old.getFiletype()); + } catch (Exception e) { + log.error("old business cert delete failed", e); + } + } } @Transactional @@ -102,6 +117,8 @@ public void updateAgencyPicture(MultipartFile file,MemberDetails memberDetails){ Member member = getMember(memberDetails.getUsername()); Agency agency = member.getAgency(); + requireFile(file); + FileAttachment old = agency.getAgencyPicture(); String oldFileId = old.getFileId(); FileTargetType oldType = old.getFiletype(); @@ -116,8 +133,8 @@ public void updateAgencyPicture(MultipartFile file,MemberDetails memberDetails){ private void requireFile(MultipartFile file) { if (file == null || file.isEmpty()) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "파일이 비었습니다."); + throw new FileHandler(FileErrorStatus.FILE_EMPTY); if (file.getSize() > 10 * 1024 * 1024L) - throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, "최대 10MB."); + throw new FileHandler(FileErrorStatus.FILE_SIZE_ERROR); } } diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/controller/adminpage/SchoolAdminRestController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/controller/adminpage/SchoolAdminRestController.java index 2073434..c7fdcb9 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/controller/adminpage/SchoolAdminRestController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/controller/adminpage/SchoolAdminRestController.java @@ -7,6 +7,8 @@ import com.cooperation.project.cooperationcenter.domain.school.service.SchoolFindService; import com.cooperation.project.cooperationcenter.domain.school.service.SchoolService; import com.cooperation.project.cooperationcenter.global.exception.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -22,40 +24,95 @@ @RequiredArgsConstructor @RequestMapping("/api/v1/admin/school") @Slf4j +@Tag( + name = "School Admin", + description = "학교 정보 및 게시판 관리 API" +) public class SchoolAdminRestController { private final SchoolService schoolService; private final SchoolFindService schoolFindService; - @PostMapping("/save") - public BaseResponse saveSchool(@RequestBody SchoolRequest.SchoolDto request){ - schoolService.saveSchool(request); - return BaseResponse.onSuccess("success"); - } + @Operation( + summary = "학교 정보 등록", + description = """ + 학교 기본 정보를 등록합니다. + """ + ) + @PostMapping("/save") + public BaseResponse saveSchool(@RequestBody SchoolRequest.SchoolDto request){ + schoolService.saveSchool(request); + return BaseResponse.onSuccess("success"); + } + + @Operation( + summary = "?숆탳 ?뺣낫 ?섏젙", + description = """ + ?숆탳 湲곕낯 ?뺣낫(援??숊삎, ?곸뼱紐?, 濡쒓퀬 URL)瑜??섏젙?⑸땲?? + """ + ) + @PatchMapping + public BaseResponse editSchool(@RequestBody SchoolRequest.SchoolEditDto request){ + schoolService.editSchool(request); + return BaseResponse.onSuccess("success"); + } + @Operation( + summary = "학교 소개 정보 저장", + description = """ + 학교 소개 페이지에 사용되는 정보를 저장합니다. + 기본 정보, 소개 문구, 대학 정보 등을 포함합니다. + """ + ) @PostMapping("/intro") public BaseResponse saveIntro(@RequestBody IntroRequest.TotalIntroSaveDto request){ schoolService.saveIntro(request); return BaseResponse.onSuccess("success"); } + @Operation( + summary = "학교 소개 정보 조회", + description = """ + 게시판 ID를 기준으로 학교 소개 정보를 조회합니다. + """ + ) @GetMapping("/intro/{boardId}") public BaseResponse getIntro(@PathVariable Long boardId){ return BaseResponse.onSuccess(schoolService.loadIntro(boardId)); } + @Operation( + summary = "학교 게시판 생성", + description = """ + 새로운 학교 게시판을 생성합니다. + """ + ) @PostMapping("/board") public BaseResponse saveBoard(@RequestBody SchoolRequest.SchoolBoardDto request){ schoolService.saveBoard(request); return BaseResponse.onSuccess("success"); } + @Operation( + summary = "학교 게시판 삭제", + description = """ + 학교 게시판을 삭제합니다. + 게시판에 포함된 게시글도 함께 삭제됩니다. + """ + ) @DeleteMapping("/board") public BaseResponse deleteBoard(@RequestBody SchoolRequest.BoardIdDto request){ schoolService.deleteBoard(request); return BaseResponse.onSuccess("success"); } + @Operation( + summary = "학교 게시글 등록", + description = """ + 학교 게시판에 새로운 게시글을 등록합니다. + 파일 업로드를 함께 처리할 수 있습니다. + """ + ) @PostMapping("/post") public BaseResponse savePost(@ModelAttribute SchoolRequest.SchoolPostDto request, @RequestPart(required = false) List files){ @@ -64,6 +121,12 @@ public BaseResponse savePost(@ModelAttribute SchoolRequest.SchoolPostDto req return BaseResponse.onSuccess("success"); } + @Operation( + summary = "학교 게시글 수정", + description = """ + 기존 학교 게시글 내용을 수정합니다. + """ + ) @PatchMapping("/post") public BaseResponse editBoard(@ModelAttribute SchoolRequest.SchoolPostDto request, @RequestPart(required = false) List files){ @@ -72,17 +135,37 @@ public BaseResponse editBoard(@ModelAttribute SchoolRequest.SchoolPostDto re return BaseResponse.onSuccess("success"); } + @Operation( + summary = "학교 게시글 삭제", + description = """ + 게시글 ID를 기준으로 학교 게시글을 삭제합니다. + """ + ) @DeleteMapping("/post") public BaseResponse deletePost(@RequestBody SchoolRequest.PostIdDto request){ schoolService.deletePost(request); return BaseResponse.onSuccess("success"); } + @Operation( + summary = "학교 게시글 단건 조회", + description = """ + 게시글 ID를 기준으로 학교 게시글을 조회합니다. + """ + ) @GetMapping("/post") public BaseResponse getPost(@RequestParam Long postId){ return BaseResponse.onSuccess(schoolFindService.getDetailPostDto(postId)); } + + @Operation( + summary = "학교 게시글 목록 조회", + description = """ + 조건 및 페이지네이션을 기반으로 + 학교 게시글 목록을 조회합니다. + """ + ) @GetMapping("/posts") public BaseResponse getPosts(@ModelAttribute SchoolRequest.PostDto request, @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) @@ -92,6 +175,12 @@ public BaseResponse getPosts(@ModelAttribute SchoolRequest.PostDto request, return BaseResponse.onSuccess(response); } + @Operation( + summary = "학교 파일 게시글 등록", + description = """ + 파일이 포함된 학교 게시글을 등록합니다. + """ + ) @PostMapping("/file") public BaseResponse saveFilePost(@ModelAttribute SchoolRequest.FilePostDto request, @RequestPart(required = false) MultipartFile files){ @@ -99,11 +188,23 @@ public BaseResponse saveFilePost(@ModelAttribute SchoolRequest.FilePostDto r return BaseResponse.onSuccess("success"); } + @Operation( + summary = "학교 파일 게시글 조회", + description = """ + 파일이 포함된 학교 게시글을 조회합니다. + """ + ) @GetMapping("/file") public BaseResponse getFilePost(@ModelAttribute SchoolRequest.PostIdDto request){ return BaseResponse.onSuccess(schoolFindService.getDetailFilePostDto(request.postId())); } + @Operation( + summary = "학교 파일 게시글 수정", + description = """ + 파일 게시글 정보를 수정합니다. + """ + ) @PatchMapping("/file") public BaseResponse editFilePost(@ModelAttribute SchoolRequest.FilePostDto request, @RequestPart(required = false) MultipartFile files){ @@ -112,6 +213,12 @@ public BaseResponse editFilePost(@ModelAttribute SchoolRequest.FilePostDto r return BaseResponse.onSuccess("success"); } + @Operation( + summary = "학교 파일 게시글 삭제", + description = """ + 파일 게시글을 삭제합니다. + """ + ) @DeleteMapping("/file") public BaseResponse deleteFilePost(@RequestBody SchoolRequest.PostIdDto request){ log.info("dto:{}",request.toString()); @@ -119,18 +226,35 @@ public BaseResponse deleteFilePost(@RequestBody SchoolRequest.PostIdDto requ return BaseResponse.onSuccess("success"); } - - + @Operation( + summary = "학사 일정 등록", + description = """ + 새로운 학사 일정을 등록합니다. + """ + ) @PostMapping("/schedule") public BaseResponse saveSchedule(@ModelAttribute SchoolRequest.ScheduleDto request){ schoolService.saveSchedule(request); return BaseResponse.onSuccess("success"); } + @Operation( + summary = "학사 일정 조회", + description = """ + 게시글 ID를 기준으로 특정 학사 일정을 조회합니다. + """ + ) @GetMapping("/schedule") public BaseResponse getSchedule(@ModelAttribute SchoolRequest.PostIdDto request){ return BaseResponse.onSuccess(schoolFindService.getScheduleDtoById(request.postId())); } + @Operation( + summary = "학사 일정 목록 조회", + description = """ + 조건 및 페이지네이션을 기반으로 + 학사 일정 목록을 조회합니다. + """ + ) @GetMapping("/schedules") public BaseResponse getSchedules(@ModelAttribute SchoolRequest.ScheduleDto request, @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) @@ -139,12 +263,25 @@ public BaseResponse getSchedules(@ModelAttribute SchoolRequest.ScheduleDto re return BaseResponse.onSuccess(schoolFindService.getScheduleDtoPageByCondition(request,pageable)); } + @Operation( + summary = "학사 일정 수정", + description = """ + 기존 학사 일정 정보를 수정합니다. + """ + ) + @PatchMapping("/schedule") public BaseResponse updateSchedule(@ModelAttribute SchoolRequest.ScheduleDto request){ schoolService.editSchedule(request); return BaseResponse.onSuccess(""); } + @Operation( + summary = "학사 일정 삭제", + description = """ + 학사 일정 ID를 기준으로 학사 일정을 삭제합니다. + """ + ) @DeleteMapping("/schedule") public BaseResponse deleteSchedule(@RequestBody SchoolRequest.PostIdDto request){ schoolService.deleteSchedule(request); diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/dto/SchoolRequest.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/dto/SchoolRequest.java index a3f962b..dd581e1 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/dto/SchoolRequest.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/dto/SchoolRequest.java @@ -6,11 +6,18 @@ import java.util.List; public class SchoolRequest { - public record SchoolDto( - String schoolKoreanName, - String schoolEnglishName, - String imgUrl - ){} + public record SchoolDto( + String schoolKoreanName, + String schoolEnglishName, + String imgUrl + ){} + + public record SchoolEditDto( + Long schoolId, + String schoolKoreanName, + String schoolEnglishName, + String imgUrl + ){} public record SchoolBoardDto( Long schoolId, diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/exception/SchoolHandler.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/exception/SchoolHandler.java new file mode 100644 index 0000000..de85557 --- /dev/null +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/exception/SchoolHandler.java @@ -0,0 +1,8 @@ +package com.cooperation.project.cooperationcenter.domain.school.exception; + +import com.cooperation.project.cooperationcenter.global.exception.BaseException; +import com.cooperation.project.cooperationcenter.global.exception.codes.BaseCode; + +public class SchoolHandler extends BaseException { + public SchoolHandler(BaseCode code){super(code);} +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/exception/status/SchoolErrorStatus.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/exception/status/SchoolErrorStatus.java new file mode 100644 index 0000000..7bfb3d2 --- /dev/null +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/exception/status/SchoolErrorStatus.java @@ -0,0 +1,40 @@ +package com.cooperation.project.cooperationcenter.domain.school.exception.status; + +import com.cooperation.project.cooperationcenter.global.exception.codes.BaseCode; +import com.cooperation.project.cooperationcenter.global.exception.codes.reason.Reason; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SchoolErrorStatus implements BaseCode { + + SCHOOL_NOT_FOUND(HttpStatus.NOT_FOUND, "SCHOOL-4001", "학교를 찾을 수 없습니다."), + SCHOOL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST,"SCHOOL-4002","해당 학교는 이미 존재합니다."), + + SCHOOL_BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "SCHOOL-4101", "게시판을 찾을 수 없습니다."), + SCHOOL_BOARD_TYPE_MATCH_ERROR(HttpStatus.NOT_FOUND, "SCHOOL-4102", "해당 타입의 School Board를 찾을 수 없습니다."), + SCHOOL_INTRO_BOARD_TYPE_ERROR(HttpStatus.NOT_FOUND, "SCHOOL-4103", "해당 타입은 소개 페이지 타입이 아닙니다."), + + SCHOOL_POST_NOT_FOUND(HttpStatus.NOT_FOUND, "SCHOOL-4201", "게시글을 찾을 수 없습니다."), + FILE_POST_NOT_FOUND(HttpStatus.NOT_FOUND, "SCHOOL-4211", "파일 게시글을 찾을 수 없습니다."), + INTRO_POST_NOT_FOUND(HttpStatus.NOT_FOUND, "SCHOOL-4221", "소개글을 찾을 수 없습니다."), + INTRO_BOARD_ALREADY_EXISTS(HttpStatus.NOT_FOUND, "SCHOOL-4222", "해당 학교는 소개글이 이미 존재합니다."), + SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "SCHOOL-4231", "일정을 찾을 수 없습니다."), + COLLEGE_NOT_FOUND(HttpStatus.NOT_FOUND,"COLLEGE-0001","해당 학과를 찾을 수가 없습니다."); + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + public Reason.ReasonDto getReasonHttpStatus() { + return Reason.ReasonDto.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/model/School.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/model/School.java index 5c1a482..7457efb 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/model/School.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/model/School.java @@ -44,11 +44,24 @@ public void deleteAllBoard() { boards.clear(); } - public static School fromDto(SchoolRequest.SchoolDto dto){ - return School.builder() - .schoolEnglishName(dto.schoolEnglishName()) - .schoolKoreanName(dto.schoolKoreanName()) - .logoUrl(dto.imgUrl()) - .build(); + public static School fromDto(SchoolRequest.SchoolDto dto){ + return School.builder() + .schoolEnglishName(dto.schoolEnglishName()) + .schoolKoreanName(dto.schoolKoreanName()) + .logoUrl(dto.imgUrl()) + .build(); + } + + public void updateInfo(SchoolRequest.SchoolEditDto dto) { + this.schoolKoreanName = dto.schoolKoreanName(); + this.schoolEnglishName = dto.schoolEnglishName(); + this.logoUrl = dto.imgUrl(); + } + + public boolean hasIntroBoard(){ + for(SchoolBoard board: boards){ + if(board.getType().equals(SchoolBoard.BoardType.INTRO)) return true; + } + return false; } } diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/repository/SchoolRepository.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/repository/SchoolRepository.java index d0c305f..411ad76 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/repository/SchoolRepository.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/repository/SchoolRepository.java @@ -5,7 +5,9 @@ import java.util.Optional; -public interface SchoolRepository extends JpaRepository { - Optional findSchoolById(Long id); - Optional findSchoolBySchoolEnglishName(String englishName); -} +public interface SchoolRepository extends JpaRepository { + Optional findSchoolById(Long id); + Optional findSchoolBySchoolEnglishName(String englishName); + boolean existsBySchoolEnglishName(String englishName); + boolean existsBySchoolEnglishNameAndIdNot(String englishName, Long id); +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/service/SchoolFindService.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/service/SchoolFindService.java index 90a2eb2..1ce42f2 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/service/SchoolFindService.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/service/SchoolFindService.java @@ -3,6 +3,8 @@ import com.cooperation.project.cooperationcenter.domain.file.model.FileAttachment; import com.cooperation.project.cooperationcenter.domain.file.repository.FileAttachmentRepository; import com.cooperation.project.cooperationcenter.domain.school.dto.*; +import com.cooperation.project.cooperationcenter.domain.school.exception.SchoolHandler; +import com.cooperation.project.cooperationcenter.domain.school.exception.status.SchoolErrorStatus; import com.cooperation.project.cooperationcenter.domain.school.model.*; import com.cooperation.project.cooperationcenter.domain.school.repository.*; import com.cooperation.project.cooperationcenter.global.exception.BaseException; @@ -36,323 +38,173 @@ public class SchoolFindService { private final FileAttachmentRepository fileAttachmentRepository; + @Transactional(readOnly = true) public List loadAllSchoolByDto(){ - try{ - return loadAllSchool().stream() - .map(SchoolResponse.SchoolDto::from) - .collect(Collectors.toList()); - }catch(Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return loadAllSchool().stream() + .map(SchoolResponse.SchoolDto::from) + .collect(Collectors.toList()); } + @Transactional(readOnly = true) public List loadAllSchoolByHomeDto(){ - try{ - return loadAllSchool().stream() - .map(SchoolResponse.SchoolHomeDto::from) - .collect(Collectors.toList()); - }catch(Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return loadAllSchool().stream() + .map(SchoolResponse.SchoolHomeDto::from) + .collect(Collectors.toList()); } + @Transactional(readOnly = true) public List loadAllSchool(){ - try{ - return schoolRepository.findAll(); - }catch(Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return schoolRepository.findAll(); } public SchoolResponse.SchoolDto loadSchoolByEnglishNameByDto(String englishName){ - try{ - return SchoolResponse.SchoolDto.from(schoolRepository.findSchoolBySchoolEnglishName(englishName).orElseThrow( - () -> new BaseException(ErrorCode.BAD_REQUEST) - )); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } + return SchoolResponse.SchoolDto.from(schoolRepository.findSchoolBySchoolEnglishName(englishName).orElseThrow( + () -> new SchoolHandler(SchoolErrorStatus.SCHOOL_NOT_FOUND) + )); } public School loadSchoolByEnglishName(String englishName){ - log.info("loadEnglishName : {}",englishName); - try{ - return schoolRepository.findSchoolBySchoolEnglishName(englishName).orElseThrow( - () -> new BaseException(ErrorCode.BAD_REQUEST) - ); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } + return schoolRepository.findSchoolBySchoolEnglishName(englishName) + .orElseThrow(() -> new SchoolHandler(SchoolErrorStatus.SCHOOL_NOT_FOUND)); } public School loadSchoolById(Long id){ - try{ - return schoolRepository.findSchoolById(id).orElseThrow(() -> new BaseException(ErrorCode.BAD_REQUEST)); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return schoolRepository.findSchoolById(id).orElseThrow( + () -> new SchoolHandler(SchoolErrorStatus.SCHOOL_NOT_FOUND)); } public List loadBoardBySchool(School school){ - try{ - return schoolBoardRepository.findBySchool(school); - }catch(Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return schoolBoardRepository.findBySchool(school); } public List loadBoardBySchoolByDto(School school,Long nowId){ - try{ - return schoolBoardRepository.findBySchool(school).stream() - .map(dto -> SchoolResponse.SchoolBoardDto.from(dto,nowId)) - .toList(); - }catch(Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return schoolBoardRepository.findBySchool(school).stream() + .map(dto -> SchoolResponse.SchoolBoardDto.from(dto,nowId)) + .toList(); } public SchoolBoard loadBoardById(Long boardId){ - try{ - return schoolBoardRepository.findSchoolBoardById(boardId).orElseThrow(()-> new BaseException(ErrorCode.BAD_REQUEST)); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return schoolBoardRepository.findSchoolBoardById(boardId).orElseThrow( + ()-> new SchoolHandler(SchoolErrorStatus.SCHOOL_BOARD_NOT_FOUND)); } public SchoolPost loadPostById(Long postId){ - try{ - return schoolPostRepository.findById(postId).orElseThrow(()-> new BaseException(ErrorCode.BAD_REQUEST)); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return schoolPostRepository.findById(postId).orElseThrow( + ()-> new SchoolHandler(SchoolErrorStatus.SCHOOL_POST_NOT_FOUND)); } public SchoolResponse.SchoolPostDto loadPostByIdByDto(Long postId){ - try{ - return SchoolResponse.SchoolPostDto.from(loadPostById(postId)); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return SchoolResponse.SchoolPostDto.from(loadPostById(postId)); } public List loadPostByBoard(SchoolBoard board){ - try{ - return schoolPostRepository.findBySchoolBoard(board); - }catch(Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return schoolPostRepository.findBySchoolBoard(board); } public Page loadPostByPage(SchoolBoard board, Pageable pageable){ - try{ - return schoolPostRepository.findBySchoolBoard(board,pageable); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return schoolPostRepository.findBySchoolBoard(board,pageable); } public Page loadFilePostByPage(SchoolBoard board, Pageable pageable){ - try{ - return filePostRepository.findFilePostBySchoolBoardAndStatus(board,pageable, PostStatus.PUBLISHED); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return filePostRepository.findFilePostBySchoolBoardAndStatus(board,pageable, PostStatus.PUBLISHED); } public Page loadFilePostByPageByKeyword(SchoolBoard board, Pageable pageable,String keyword){ - try{ - return filePostRepository.findFilePostBySchoolBoardAndStatusAndPostTitleContainingIgnoreCase(board, PostStatus.PUBLISHED,keyword,pageable); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return filePostRepository.findFilePostBySchoolBoardAndStatusAndPostTitleContainingIgnoreCase(board, PostStatus.PUBLISHED,keyword,pageable); } public List loadNoticeFilePostByPageByKeyword(SchoolBoard board,String keyword){ - try{ - return filePostRepository.findFilePostBySchoolBoardAndStatusAndPostTitleContainingIgnoreCaseAndType(board, PostStatus.PUBLISHED,keyword, PostType.NOTICE); - }catch(Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return filePostRepository.findFilePostBySchoolBoardAndStatusAndPostTitleContainingIgnoreCaseAndType(board, PostStatus.PUBLISHED,keyword, PostType.NOTICE); } public List loadNormalFilePostByPageByKeyword(SchoolBoard board,String keyword){ - try{ - return filePostRepository.findFilePostBySchoolBoardAndStatusAndPostTitleContainingIgnoreCaseAndType(board, PostStatus.PUBLISHED,keyword,PostType.NORMAL); - }catch(Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return filePostRepository.findFilePostBySchoolBoardAndStatusAndPostTitleContainingIgnoreCaseAndType(board, PostStatus.PUBLISHED,keyword,PostType.NORMAL); } - - - public List loadPostByBoardByDto(SchoolBoard board){ - try{ - return schoolPostRepository.findBySchoolBoard(board).stream() - .map(SchoolResponse.SchoolPostDto::from) - .collect(Collectors.toList()); - }catch(Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return schoolPostRepository.findBySchoolBoard(board).stream() + .map(SchoolResponse.SchoolPostDto::from) + .collect(Collectors.toList()); } public List loadNoticePostByBoardAndKeyword(SchoolBoard board, Pageable pageable, String keyword){ PostType type = PostType.NOTICE; PostStatus status = PostStatus.PUBLISHED; - try{ - return schoolPostRepository.searchPosts(board,type,status,keyword); - }catch (Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return schoolPostRepository.searchPosts(board,type,status,keyword); } public List loadNormalPostByBoardAndKeyword(SchoolBoard board, Pageable pageable, String keyword){ PostType type = PostType.NORMAL; PostStatus status = PostStatus.PUBLISHED; - try{ - return schoolPostRepository.searchPosts(board,type,status,keyword); - }catch (Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return schoolPostRepository.searchPosts(board,type,status,keyword); } public Page loadPostByBoardByDto(SchoolBoard board,Pageable pageable,String keyword){ - try{ - List noticePage = SchoolResponse.SchoolPostDto.from(loadNoticePostByBoardAndKeyword(board,pageable,keyword)); - List normalPage = SchoolResponse.SchoolPostDto.from(loadNormalPostByBoardAndKeyword(board,pageable,keyword)); - - List merged = new ArrayList<>(); - merged.addAll(noticePage); - merged.addAll(normalPage); + List noticePage = SchoolResponse.SchoolPostDto.from(loadNoticePostByBoardAndKeyword(board,pageable,keyword)); + List normalPage = SchoolResponse.SchoolPostDto.from(loadNormalPostByBoardAndKeyword(board,pageable,keyword)); - int total = merged.size(); - return new PageImpl<>(merged, pageable, total); + List merged = new ArrayList<>(); + merged.addAll(noticePage); + merged.addAll(normalPage); - }catch(Exception e){ - log.warn(e.getMessage()); - return Page.empty(); - } + int total = merged.size(); + return new PageImpl<>(merged, pageable, total); } public Page loadPostPageByBoardByDto(SchoolBoard board,Pageable pageable){ - try{ - return loadPostByPage(board,pageable) - .map(SchoolResponse.SchoolPostDto::from); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return loadPostByPage(board,pageable) + .map(SchoolResponse.SchoolPostDto::from); } public Page getFilePostPageByBoardByDto(SchoolBoard board,Pageable pageable){ - try{ - return loadFilePostByPage(board,pageable) - .map(SchoolResponse.SchoolPostDto::from); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return loadFilePostByPage(board,pageable) + .map(SchoolResponse.SchoolPostDto::from); } public Page getFilePostPageByBoardByDto(SchoolBoard board,Pageable pageable,String keyword){ - try{ - List noticeFilePost = SchoolResponse.SchoolPostDto.fromFilePost(loadNoticeFilePostByPageByKeyword(board,keyword)); - List normalFilePost = SchoolResponse.SchoolPostDto.fromFilePost(loadNormalFilePostByPageByKeyword(board,keyword)); - List merged = new ArrayList<>(); - - merged.addAll(noticeFilePost); - merged.addAll(normalFilePost); + List noticeFilePost = SchoolResponse.SchoolPostDto.fromFilePost(loadNoticeFilePostByPageByKeyword(board,keyword)); + List normalFilePost = SchoolResponse.SchoolPostDto.fromFilePost(loadNormalFilePostByPageByKeyword(board,keyword)); + List merged = new ArrayList<>(); - int total = merged.size(); - log.info("find file post size : {}",total); - return new PageImpl<>(merged,pageable,total); + merged.addAll(noticeFilePost); + merged.addAll(normalFilePost); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + int total = merged.size(); + log.info("find file post size : {}",total); + return new PageImpl<>(merged,pageable,total); } public SchoolPost getBeforePostById(Long postId, SchoolBoard board){ - try{ - return schoolPostRepository.findTopBySchoolBoardAndIdLessThanOrderByIdDesc(board,postId).orElse(null); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } + return schoolPostRepository.findTopBySchoolBoardAndIdLessThanOrderByIdDesc(board,postId).orElse(null); } public SchoolPost getAfterPostById(Long postId, SchoolBoard board){ - try{ - return schoolPostRepository.findTopBySchoolBoardAndIdGreaterThanOrderByIdAsc(board,postId).orElse(null); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } + return schoolPostRepository.findTopBySchoolBoardAndIdGreaterThanOrderByIdAsc(board,postId).orElse(null); } public FilePost getBeforeFilePostById(Long postId, SchoolBoard board){ - try{ - return filePostRepository.findTopBySchoolBoardAndIdLessThanOrderByIdDesc(board,postId).orElse(null); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } + return filePostRepository.findTopBySchoolBoardAndIdLessThanOrderByIdDesc(board,postId).orElseThrow( + () -> new SchoolHandler(SchoolErrorStatus.FILE_POST_NOT_FOUND) + ); } public FilePost getAfterFilePostById(Long postId, SchoolBoard board){ - try{ - return filePostRepository.findTopBySchoolBoardAndIdGreaterThanOrderByIdAsc(board,postId).orElse(null); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } + return filePostRepository.findTopBySchoolBoardAndIdGreaterThanOrderByIdAsc(board,postId).orElseThrow( + () -> new SchoolHandler(SchoolErrorStatus.FILE_POST_NOT_FOUND) + ); } - - - public IntroPost loadIntroById(Long introId){ - try{ - return introPostRepository.findIntroPostById(introId).orElseThrow( - () -> new BaseException(ErrorCode.BAD_REQUEST) - ); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return introPostRepository.findIntroPostById(introId).orElseThrow( + () -> new SchoolHandler(SchoolErrorStatus.INTRO_POST_NOT_FOUND) + ); } public IntroPost loadIntroByBoard(SchoolBoard schoolBoard){ - try{ - return introPostRepository.findIntroPostsBySchoolBoard(schoolBoard).orElseThrow( - () -> new BaseException(ErrorCode.BAD_REQUEST) - ); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } + return introPostRepository.findIntroPostsBySchoolBoard(schoolBoard).orElseThrow( + () -> new SchoolHandler(SchoolErrorStatus.INTRO_POST_NOT_FOUND) + ); } public List getSchoolPage(){ @@ -363,30 +215,17 @@ public List getSchoolPage(){ } public List loadCollegesByIntro(IntroPost introPost){ - try{ - return collegeRepository.findCollegesByIntroPost(introPost); - }catch (Exception e){ - return Collections.emptyList(); - } + return collegeRepository.findCollegesByIntroPost(introPost); } public College loadCollegesById(Long id){ - try{ - return collegeRepository.findCollegeById(id).orElseThrow( - () -> new BaseException(ErrorCode.COLLEGE_NOT_FOUND) - ); - }catch (Exception e){ - return null; - } + return collegeRepository.findCollegeById(id).orElseThrow( + () -> new SchoolHandler(SchoolErrorStatus.COLLEGE_NOT_FOUND) + ); } public List loadFileByPost(SchoolPost schoolPost){ - try{ - return fileAttachmentRepository.findFileAttachmentsBySchoolPost(schoolPost); - }catch (Exception e){ - log.warn("post file가져오는데 실패"); - return Collections.emptyList(); - } + return fileAttachmentRepository.findFileAttachmentsBySchoolPost(schoolPost); } public List loadFileByPost(long postId){ @@ -409,23 +248,14 @@ public SchoolResponse.PostDetailDto getDetailPostDto(Long postId){ SchoolResponse.SchoolPostDto dto = SchoolResponse.SchoolPostDto.from(schoolPost); //fixme 수정하기 QueryDSL로 - try{ - SchoolPost beforePost = schoolPostQSDLRepository.findBeforePost(schoolPost); - SchoolPost afterPost = schoolPostQSDLRepository.findAfterPost(schoolPost); - - return new SchoolResponse.PostDetailDto( - dto, - loadPostFileByPost(postId), - (beforePost==null)? null: SchoolResponse.SchoolPostSimpleDto.from(beforePost), - (afterPost==null)? null: SchoolResponse.SchoolPostSimpleDto.from(afterPost)); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - - - + SchoolPost beforePost = schoolPostQSDLRepository.findBeforePost(schoolPost); + SchoolPost afterPost = schoolPostQSDLRepository.findAfterPost(schoolPost); + return new SchoolResponse.PostDetailDto( + dto, + loadPostFileByPost(postId), + (beforePost==null)? null: SchoolResponse.SchoolPostSimpleDto.from(beforePost), + (afterPost==null)? null: SchoolResponse.SchoolPostSimpleDto.from(afterPost)); } @Transactional @@ -448,76 +278,38 @@ public SchoolResponse.FilePostDetailDto getDetailFilePostDto(Long postId){ public FilePost loadFilePostById(Long postId){ - try{ - return filePostRepository.findFilePostById(postId).orElseThrow( - () -> new BaseException(ErrorCode.BAD_REQUEST) - ); - }catch(Exception e){ - log.warn(e.getMessage()); - return null; - } + return filePostRepository.findFilePostById(postId).orElseThrow( + () -> new SchoolHandler(SchoolErrorStatus.FILE_POST_NOT_FOUND) + ); } public SchoolSchedule loadScheduleById(Long id){ - try{ - return schoolScheduleRepository.findById(id).orElseThrow( - () -> new BaseException(ErrorCode.BAD_REQUEST) - ); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } + return schoolScheduleRepository.findById(id).orElseThrow( + () -> new SchoolHandler(SchoolErrorStatus.SCHEDULE_NOT_FOUND) + ); } public SchoolResponse.ScheduleDto getScheduleDtoById(Long id){ - try{ - return SchoolResponse.ScheduleDto.from(loadScheduleById(id)); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } + return SchoolResponse.ScheduleDto.from(loadScheduleById(id)); } public List loadScheduleByBoard(SchoolBoard schoolBoard){ - try{ - return schoolScheduleRepository.findSchoolSchedulesBySchoolBoard(schoolBoard); - }catch (Exception e){ - log.warn(e.getMessage()); - log.warn("schedule가져오는거 실패"); - return Collections.emptyList(); - } + return schoolScheduleRepository.findSchoolSchedulesBySchoolBoard(schoolBoard); } + public List loadScheduleDtoByBoard(SchoolBoard schoolBoard){ - try{ - return SchoolResponse.ScheduleDto.from(loadScheduleByBoard(schoolBoard)); - }catch (Exception e){ - log.warn(e.getMessage()); - log.warn("dto생성 실패"); - return Collections.emptyList(); - } + return SchoolResponse.ScheduleDto.from(loadScheduleByBoard(schoolBoard)); } public Page loadSchedulesPageByCondition(SchoolRequest.ScheduleDto request,Pageable pageable){ - try{ - ScheduleType type = (request.type()==null)? null : ScheduleType.from(request.type()); - return schoolScheduleRepository.findSchedulesByBoardAndFlexibleDate(request.boardId(),request.startDate(),request.endDate(),request.title(),type,pageable); - }catch (Exception e){ - log.warn(e.getMessage()); - return Page.empty(); - } + ScheduleType type = (request.type()==null)? null : ScheduleType.from(request.type()); + return schoolScheduleRepository.findSchedulesByBoardAndFlexibleDate(request.boardId(),request.startDate(),request.endDate(),request.title(),type,pageable); } public Page getScheduleDtoPageByCondition(SchoolRequest.ScheduleDto request,Pageable pageable){ - try{ - return SchoolResponse.ScheduleDto.from(loadSchedulesPageByCondition(request,pageable)); - }catch (Exception e){ - log.warn(e.getMessage()); - return Page.empty(); - } + return SchoolResponse.ScheduleDto.from(loadSchedulesPageByCondition(request,pageable)); } - - @Transactional public void increasePostView(Long postId) { schoolPostRepository.incrementViewCount(postId); // 방법 1 사용 diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/service/SchoolService.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/service/SchoolService.java index d0692cc..dce550d 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/school/service/SchoolService.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/school/service/SchoolService.java @@ -4,6 +4,8 @@ import com.cooperation.project.cooperationcenter.domain.file.model.FileAttachment; import com.cooperation.project.cooperationcenter.domain.file.service.FileService; import com.cooperation.project.cooperationcenter.domain.school.dto.*; +import com.cooperation.project.cooperationcenter.domain.school.exception.SchoolHandler; +import com.cooperation.project.cooperationcenter.domain.school.exception.status.SchoolErrorStatus; import com.cooperation.project.cooperationcenter.domain.school.model.*; import com.cooperation.project.cooperationcenter.domain.school.repository.*; import io.jsonwebtoken.lang.Collections; @@ -21,6 +23,8 @@ import java.util.Set; import java.util.stream.Collectors; +import static com.cooperation.project.cooperationcenter.domain.school.model.SchoolBoard.BoardType.INTRO; + @Service @RequiredArgsConstructor @Slf4j @@ -38,17 +42,34 @@ public class SchoolService { private final CollegeRepository collegeRepository; @Transactional - public void saveSchool(SchoolRequest.SchoolDto request){ - School school = School.fromDto(request); - schoolRepository.save(school); - } + public void saveSchool(SchoolRequest.SchoolDto request){ + + if (schoolRepository.existsBySchoolEnglishName(request.schoolEnglishName())) { + throw new SchoolHandler(SchoolErrorStatus.SCHOOL_ALREADY_EXISTS); + } + + School school = School.fromDto(request); + schoolRepository.save(school); + } + + @Transactional + public void editSchool(SchoolRequest.SchoolEditDto request) { + School school = schoolFindService.loadSchoolById(request.schoolId()); + + if (schoolRepository.existsBySchoolEnglishNameAndIdNot(request.schoolEnglishName(), request.schoolId())) { + throw new SchoolHandler(SchoolErrorStatus.SCHOOL_ALREADY_EXISTS); + } + + school.updateInfo(request); + } @Transactional public void saveBoard(SchoolRequest.SchoolBoardDto request){ School school = schoolFindService.loadSchoolById(request.schoolId()); SchoolBoard schoolBoard = SchoolBoard.fromDto(request); - if(schoolBoard.getType().equals(SchoolBoard.BoardType.INTRO)){ + if(schoolBoard.getType().equals(INTRO)){ + if(school.hasIntroBoard()) throw new SchoolHandler(SchoolErrorStatus.INTRO_BOARD_ALREADY_EXISTS); IntroPost introPost = IntroPost.builder() .schoolBoard(schoolBoard) .build(); @@ -86,17 +107,15 @@ public void saveSchedule(SchoolRequest.ScheduleDto request){ @Transactional public void saveIntro(IntroRequest.TotalIntroSaveDto request){ - try{ - SchoolBoard board = schoolFindService.loadBoardById(request.boardId()); - IntroPost intro = board.getIntroPost(); - intro.fromDto(request); - //todo collegeDto는 따로 저쟝해야함. 저장이 없어서 사라지는듯 - intro.updateCollege(saveCollege(intro,request.collegeDto())); - schoolBoardRepository.save(board); - } - catch(Exception e){ - log.warn(e.getMessage()); + SchoolBoard board = schoolFindService.loadBoardById(request.boardId()); + IntroPost intro = board.getIntroPost(); + if (intro == null) { + throw new SchoolHandler(SchoolErrorStatus.INTRO_POST_NOT_FOUND); } + intro.fromDto(request); + //todo collegeDto는 따로 저쟝해야함. 저장이 없어서 사라지는듯 + intro.updateCollege(saveCollege(intro,request.collegeDto())); + schoolBoardRepository.save(board); } @Transactional diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/student/controller/adminpage/StudentAdminRestController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/student/controller/adminpage/StudentAdminRestController.java index 1579366..a3af47d 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/student/controller/adminpage/StudentAdminRestController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/student/controller/adminpage/StudentAdminRestController.java @@ -3,6 +3,8 @@ import com.cooperation.project.cooperationcenter.domain.student.dto.StudentRequest; import com.cooperation.project.cooperationcenter.domain.student.service.StudentService; import com.cooperation.project.cooperationcenter.global.exception.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; @@ -20,21 +22,43 @@ @RequiredArgsConstructor @RequestMapping("/api/v1/admin/students") @Slf4j +@Tag( + name = "Student Admin", + description = "학생 정보 관리 API" +) public class StudentAdminRestController { private final StudentService studentService; + @Operation( + summary = "전체 학생 목록 조회", + description = """ + 관리자 권한으로 전체 학생 목록을 조회합니다. + """ + ) @GetMapping public BaseResponse getAllStudent(){ return BaseResponse.onSuccess(studentService.getAllStudentDto()); } + @Operation( + summary = "학생 상세 조회", + description = """ + 학생 ID를 기준으로 학생 상세 정보를 조회합니다. + """ + ) @GetMapping("/{id}") public BaseResponse getStudentById(@PathVariable Long id){ log.info("enter get student / "+id); return BaseResponse.onSuccess(studentService.getStudentDtoById(id)); } + @Operation( + summary = "학생 목록 조건 다운로드", + description = """ + 조건에 맞는 학생 데이터를 파일 형태로 다운로드합니다. + """ + ) @GetMapping("/download") public ResponseEntity downloadStudentsByCondition(@ModelAttribute StudentRequest.ConditionDto condition){ log.info("condition:{}",condition.toString()); diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/student/exception/StudentHandler.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/student/exception/StudentHandler.java new file mode 100644 index 0000000..593742a --- /dev/null +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/student/exception/StudentHandler.java @@ -0,0 +1,9 @@ +package com.cooperation.project.cooperationcenter.domain.student.exception; + +import com.cooperation.project.cooperationcenter.global.exception.BaseException; +import com.cooperation.project.cooperationcenter.global.exception.BaseResponse; +import com.cooperation.project.cooperationcenter.global.exception.codes.BaseCode; + +public class StudentHandler extends BaseException { + public StudentHandler(BaseCode code){super(code);} +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/student/exception/status/StudentErrorStatus.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/student/exception/status/StudentErrorStatus.java new file mode 100644 index 0000000..6ab15e4 --- /dev/null +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/student/exception/status/StudentErrorStatus.java @@ -0,0 +1,34 @@ +package com.cooperation.project.cooperationcenter.domain.student.exception.status; + +import com.cooperation.project.cooperationcenter.global.exception.codes.BaseCode; +import com.cooperation.project.cooperationcenter.global.exception.codes.reason.Reason; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum StudentErrorStatus implements BaseCode { + + STUDENT_NOT_FOUND(HttpStatus.NOT_FOUND, "STUDENT-4041", "학생 정보를 찾을 수 없습니다."), + STUDENT_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "STUDENT-5001", "학생 저장에 실패했습니다."), + + STUDENT_SURVEY_ANSWER_EMPTY(HttpStatus.BAD_REQUEST, "STUDENT-4001", "설문 답변이 존재하지 않습니다."), + STUDENT_SURVEY_LOG_NOT_FOUND(HttpStatus.BAD_REQUEST, "STUDENT-4002", "설문 로그를 찾을 수 없습니다."), + STUDENT_MAPPING_FAILED(HttpStatus.BAD_REQUEST, "STUDENT-4003", "학생 정보 매핑에 실패했습니다."), + + STUDENT_EXCEL_EXPORT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "STUDENT-5002", "학생 엑셀 파일 생성에 실패했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + public Reason.ReasonDto getReasonHttpStatus() { + return Reason.ReasonDto.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/student/service/StudentService.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/student/service/StudentService.java index c166254..3da6cce 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/student/service/StudentService.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/student/service/StudentService.java @@ -4,6 +4,8 @@ import com.cooperation.project.cooperationcenter.domain.member.model.Member; import com.cooperation.project.cooperationcenter.domain.student.dto.StudentRequest; import com.cooperation.project.cooperationcenter.domain.student.dto.StudentResponse; +import com.cooperation.project.cooperationcenter.domain.student.exception.StudentHandler; +import com.cooperation.project.cooperationcenter.domain.student.exception.status.StudentErrorStatus; import com.cooperation.project.cooperationcenter.domain.student.model.Student; import com.cooperation.project.cooperationcenter.domain.student.repository.StudentRepository; import com.cooperation.project.cooperationcenter.domain.student.repository.StudentRepositoryCustom; @@ -49,18 +51,24 @@ public class StudentService { "비상연락처","소속(Agency)" }; + + + @Transactional public void addStudentBySurvey(List questionList, List savedAnswer, Member member){ + log.info("before changing answer to Student"); - int questionLen = questionList.size(); - int answerLen = savedAnswer.size(); - if(questionLen!=answerLen) log.info("문항 답변 개수 다름"); + if (savedAnswer == null || savedAnswer.isEmpty()) throw new StudentHandler(StudentErrorStatus.STUDENT_SURVEY_ANSWER_EMPTY); - log.info("=======original Answer List========"); - for(Answer an : savedAnswer){ - log.info("values:{}",an.getAnswer()); - } +// int questionLen = questionList.size(); +// int answerLen = savedAnswer.size(); +// if(questionLen!=answerLen) log.info("문항 답변 개수 다름"); +// log.info("=======original Answer List========"); +// for(Answer an : savedAnswer){ +// log.info("values:{}",an.getAnswer()); +// } SurveyLog surveyLog = savedAnswer.get(0).getSurveyLog(); + if (surveyLog == null) throw new BaseException(StudentErrorStatus.STUDENT_SURVEY_LOG_NOT_FOUND); Map answerByQid = savedAnswer.stream() .filter(Objects::nonNull) .filter(a -> a.getQuestionRealId() != null) @@ -71,11 +79,6 @@ public void addStudentBySurvey(List questionList, List savedAn LinkedHashMap::new )); - log.info("=======AnswerQid List========"); - for(Answer an : answerByQid.values()){ - log.info("an:{}",an.getAnswer()); - } - Map domainMap = new LinkedHashMap<>(); for(Question q : questionList){ @@ -83,22 +86,17 @@ public void addStudentBySurvey(List questionList, List savedAn if(!q.isTemplate()) continue; String domainField = q.getDomainField(); Answer answer = answerByQid.get(q.getQuestionId()); - if(answer == null){ - log.warn("템플릿 문항에 대한 답변이 없습니다. questionId:{}, question:{}", q.getQuestionId(), q.getQuestion()); - continue; - } + if(answer == null) throw new StudentHandler(StudentErrorStatus.STUDENT_SURVEY_ANSWER_EMPTY); String generateAnswer = answer.getAnswer(); - if(generateAnswer == null){ - log.warn("템플릿 문항 답변 값이 null 입니다. questionId:{}, question:{}", q.getQuestionId(), q.getQuestion()); - continue; - } - + if(generateAnswer == null) throw new StudentHandler(StudentErrorStatus.STUDENT_SURVEY_ANSWER_EMPTY); domainMap.put(domainField,generateAnswer); } - log.info("=======domain List========"); - for(String str : domainMap.values()){ - log.info("values:{}",str); - } + if (domainMap.isEmpty()) throw new BaseException(StudentErrorStatus.STUDENT_MAPPING_FAILED); + +// log.info("=======domain List========"); +// for(String str : domainMap.values()){ +// log.info("values:{}",str); +// } ObjectMapper mapper = new ObjectMapper() .registerModule(new JavaTimeModule()) @@ -120,7 +118,7 @@ public byte[] exportStudentsExcel(StudentRequest.ConditionDto condition){ return buildExcel(rows); }catch (Exception e){ log.warn(e.getMessage()); - return null; + throw new BaseException(StudentErrorStatus.STUDENT_EXCEL_EXPORT_FAILED); } } @@ -130,25 +128,16 @@ public void saveStudent(Student student){ studentRepository.save(student); }catch (Exception e){ log.warn(e.getMessage()); + throw new BaseException(StudentErrorStatus.STUDENT_SAVE_FAILED); } } public Page getStudentDtoPageByCondition(StudentRequest.ConditionDto condition, Pageable pageable){ - try{ - return StudentResponse.ListDto.from(loadStudentPageByCondition(condition,pageable)); - }catch (Exception e){ - log.warn(e.getMessage()); - return Page.empty(); - } + return StudentResponse.ListDto.from(loadStudentPageByCondition(condition,pageable)); } public List getStudentDtoByCondition(StudentRequest.ConditionDto condition){ - try{ - return StudentResponse.ListDto.from(loadStudentDtoByCondition(condition)); - }catch (Exception e){ - log.warn(e.getMessage()); - return Collections.emptyList(); - } + return StudentResponse.ListDto.from(loadStudentDtoByCondition(condition)); } diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/controller/homepage/SurveyRestController.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/controller/homepage/SurveyRestController.java index fa2dbf6..abcf77a 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/controller/homepage/SurveyRestController.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/controller/homepage/SurveyRestController.java @@ -8,6 +8,7 @@ import com.cooperation.project.cooperationcenter.global.exception.BaseResponse; import com.fasterxml.jackson.core.JsonProcessingException; import com.google.zxing.WriterException; +import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,9 +38,13 @@ public class SurveyRestController { private final SurveyQRService surveyQRService; private final SurveyFolderService surveyFolderService; - - //Note 설문조사 보여주는 controller - + @Operation( + summary = "설문 목록 조회", + description = """ + 페이지네이션 및 조건(title, surveyType)을 기반으로 + 설문 목록을 조회합니다. + """ + ) @GetMapping("/list") public BaseResponse getSurveyList(@PageableDefault(size = 9, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, @@ -49,6 +54,13 @@ public BaseResponse getSurveyList(@PageableDefault(size = 9, sort = "createdA return BaseResponse.onSuccess(surveyFindService.getFilteredSurveysAll(pageable,new SurveyRequest.LogFilterDto(title,null,null,Survey.SurveyType.getSruveyType(surveyType)),null)); } + @Operation( + summary = "설문 상세 조회", + description = """ + 설문 ID를 기반으로 설문 제목, 설명, 질문 목록을 조회합니다. + 설문 참여 화면에서 사용됩니다. + """ + ) @GetMapping("/{surveyId}") public BaseResponse getSurvey(@PathVariable String surveyId){ log.info("[controller] getSurvey 진입 : {}",surveyId); @@ -56,6 +68,13 @@ public BaseResponse getSurvey(@PathVariable String surveyId){ return BaseResponse.onSuccess(surveySaveService.getSurveys(surveyId)); } + @Operation( + summary = "설문 생성", + description = """ + 질문 목록을 포함한 새로운 설문을 생성합니다. + 관리자 페이지에서 설문 작성 시 사용됩니다. + """ + ) @PostMapping("/admin/make") public BaseResponse saveSurvey(@RequestBody SurveyRequest.SurveyDto request){ log.info("[controller] {}",request.toString()); @@ -63,6 +82,13 @@ public BaseResponse saveSurvey(@RequestBody SurveyRequest.SurveyDto request){ return BaseResponse.onSuccess("success"); } + @Operation( + summary = "설문 삭제", + description = """ + 설문 ID를 기준으로 설문을 삭제합니다. + 삭제된 설문은 복구할 수 없습니다. + """ + ) @DeleteMapping("/admin/{surveyId}") public BaseResponse deleteSurvey(@PathVariable String surveyId){ log.info("[controller] getSurvey 진입 : {}",surveyId); @@ -70,6 +96,13 @@ public BaseResponse deleteSurvey(@PathVariable String surveyId){ return BaseResponse.onSuccess("success"); } + @Operation( + summary = "설문 복사", + description = """ + 기존 설문을 복사하여 새로운 설문으로 생성합니다. + 질문 구성과 설정이 함께 복사됩니다. + """ + ) @PostMapping("/admin/copy/{surveyId}") public BaseResponse copySurvey(@PathVariable String surveyId){ log.info("[controller] getSurvey 진입 : {}",surveyId); @@ -78,6 +111,12 @@ public BaseResponse copySurvey(@PathVariable String surveyId){ } + @Operation( + summary = "설문 수정", + description = """ + 기존 설문의 제목, 설명, 질문 정보를 수정합니다. + """ + ) @PatchMapping("/admin/edit") public BaseResponse editSurvey(@RequestBody SurveyEditDto request){ log.info("[controller] getSurvey 진입 : {}",request.surveyId()); @@ -88,6 +127,13 @@ public BaseResponse editSurvey(@RequestBody SurveyEditDto request){ //note 설문조사 답변 및 로그 확인 + @Operation( + summary = "설문 응답 제출", + description = """ + 사용자가 작성한 설문 응답 데이터를 서버에 제출합니다. + 제출된 응답은 설문 통계 및 응답 로그로 저장됩니다. + """ + ) @PostMapping("/answer") public BaseResponse receiveSurveyAnswer( @RequestPart("data") String data, @@ -106,6 +152,12 @@ public BaseResponse receiveSurveyAnswer( return BaseResponse.onSuccess("succeess"); } + @Operation( + summary = "전체 설문 응답 로그 조회", + description = """ + 관리자 권한으로 전체 설문 응답 로그를 조회합니다. + """ + ) @GetMapping("/admin/answer") public BaseResponse getAllAnswerLog(){ List result = surveyLogService.getAllAnswerLog(); @@ -113,6 +165,12 @@ public BaseResponse getAllAnswerLog(){ return BaseResponse.onSuccess(result); } + @Operation( + summary = "설문별 응답 로그 조회", + description = """ + 특정 설문에 대한 응답 로그를 조회합니다. + """ + ) @GetMapping("/admin/answer/{surveyId}") public BaseResponse getAnswerLog(@PathVariable String surveyId){ AnswerResponse.AnswerDto result = surveyLogService.getAnswerLog(surveyId); @@ -126,21 +184,47 @@ public ResponseEntity extractCsv(@RequestBody LogCsv.Requ return surveyLogService.extractCsv(request); } + @Operation( + summary = "설문 응답 CSV 추출", + description = """ + 특정 설문에 대한 응답 데이터를 CSV 파일로 스트리밍 다운로드합니다. + """ + ) @PostMapping("/admin/log/{surveyId}") public ResponseEntity extractCsv(@PathVariable String surveyId){ log.info("extracy all csv..."); return surveyLogService.extractAllCsv(surveyId); } + @Operation( + summary = "설문 응답 파일 다운로드 (학생 기준)", + description = """ + 학생 기준으로 업로드된 설문 응답 파일을 응답한 학생 별로 파일을 만들어서 다운로드합니다. + """ + ) @PostMapping("/admin/log/file/student/{surveyId}") public ResponseEntity extractFileStudent(@PathVariable String surveyId){ return surveyLogService.extractFileStudent(surveyId); } + + @Operation( + summary = "설문 응답 파일 다운로드 (설문 기준)", + description = """ + 설문 응답에 포함된 파일 업로드 데이터를 설문조사 별로 파일을 만들어 다운로드합니다. + """ + ) @PostMapping("/admin/log/file/survey/{surveyId}") public ResponseEntity extractFileSurvey(@PathVariable String surveyId){ return surveyLogService.extractFileSurvey(surveyId); } + @Operation( + summary = "설문 QR 코드 생성", + description = """ + 설문 접근 URL을 기반으로 QR 코드 데이터를 생성합니다. + 오프라인 배포용으로 사용됩니다. + """ + ) @GetMapping("/qr") public Object cerateQR(@RequestParam String url,HttpServletRequest request) throws WriterException, IOException { log.info("url:{}",url); @@ -150,29 +234,60 @@ public Object cerateQR(@RequestParam String url,HttpServletRequest request) thro .body(Qr); } + @Operation( + summary = "설문 템플릿 조회", + description = """ + 설문 타입에 따라 미리 정의된 기본 질문 템플릿을 조회합니다. + """ + ) @GetMapping("/admin/template") public BaseResponse getTemplate(@RequestParam("type") String type){ log.info("enter controller type:{}",type); return BaseResponse.onSuccess(surveySaveService.getTemplate(type)); } + @Operation( + summary = "설문 폴더 목록 조회", + description = """ + 관리자가 생성한 설문 폴더 목록을 조회합니다. + """ + ) @GetMapping("/admin/folders") public BaseResponse getFolders(){ return BaseResponse.onSuccess(surveyFolderService.getSurveyFolderDtos()); } + @Operation( + summary = "설문 폴더 생성", + description = """ + 설문을 분류하기 위한 새로운 폴더를 생성합니다. + """ + ) @PostMapping("/admin/folders") public BaseResponse makeFolder(@RequestBody SurveyFolderDto request,@AuthenticationPrincipal MemberDetails memberDetails){ surveyFolderService.saveSurveyFolderDto(request,memberDetails); return BaseResponse.onSuccess("success"); } + @Operation( + summary = "설문 폴더 수정", + description = """ + 설문 폴더의 이름 또는 정보를 수정합니다. + """ + ) @PatchMapping("/admin/folders/{folderId}") public BaseResponse updateFolder(@RequestBody SurveyFolderDto request){ surveyFolderService.updateSurveyFolderDto(request); return BaseResponse.onSuccess("success"); } + @Operation( + summary = "설문 폴더 삭제", + description = """ + 설문 폴더를 삭제합니다. + 폴더 내 설문은 삭제되지 않습니다. + """ + ) @DeleteMapping("/admin/folders/{folderId}") public BaseResponse deleteFolder(@PathVariable String folderId){ surveyFolderService.deleteSurveyFolder(folderId); diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/service/homepage/SurveyAnswerService.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/service/homepage/SurveyAnswerService.java index 6423e54..bbfed08 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/service/homepage/SurveyAnswerService.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/service/homepage/SurveyAnswerService.java @@ -4,6 +4,8 @@ import com.cooperation.project.cooperationcenter.domain.file.model.FileAttachment; import com.cooperation.project.cooperationcenter.domain.file.service.FileService; import com.cooperation.project.cooperationcenter.domain.member.dto.MemberDetails; +import com.cooperation.project.cooperationcenter.domain.member.exception.MemberHandler; +import com.cooperation.project.cooperationcenter.domain.member.exception.status.MemberErrorStatus; import com.cooperation.project.cooperationcenter.domain.member.model.Member; import com.cooperation.project.cooperationcenter.domain.member.repository.MemberRepository; import com.cooperation.project.cooperationcenter.domain.student.service.StudentService; @@ -67,7 +69,7 @@ public void answerSurvey(String data, HttpServletRequest request, MemberDetails Member member = memberRepository.findMemberByEmail(memberDetails.getUsername()).orElseThrow( - () -> new BaseException(ErrorCode.MEMBER_NOT_FOUND) + () -> new MemberHandler(MemberErrorStatus.MEMBER_NOT_FOUND) ); log.info("find Member"); diff --git a/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/service/homepage/SurveyFindService.java b/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/service/homepage/SurveyFindService.java index 4dcbf04..1dcc55e 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/service/homepage/SurveyFindService.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/domain/survey/service/homepage/SurveyFindService.java @@ -1,292 +1,228 @@ -package com.cooperation.project.cooperationcenter.domain.survey.service.homepage; - -import com.cooperation.project.cooperationcenter.domain.member.model.Member; -import com.cooperation.project.cooperationcenter.domain.survey.dto.SurveyRequest; -import com.cooperation.project.cooperationcenter.domain.survey.dto.SurveyResponseDto; -import com.cooperation.project.cooperationcenter.domain.survey.model.*; -import com.cooperation.project.cooperationcenter.domain.survey.repository.*; -import com.cooperation.project.cooperationcenter.global.exception.BaseException; -import com.cooperation.project.cooperationcenter.global.exception.codes.ErrorCode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - -import java.time.LocalDate; -import java.time.Period; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -@Service -@RequiredArgsConstructor -@Slf4j -public class SurveyFindService { - private final SurveyRepository surveyRepository; - private final QuestionOptionRepository questionOptionRepository; - private final QuestionRepository questionRepository; - private final SurveyLogRepository surveyLogRepository; - private final AnswerRepository answerRepository; - private final SurveyFolderRepository surveyFolderRepository; - - public Survey getSurveyFromId(Long surveyId){ - try{ - return surveyRepository.findSurveyById(surveyId); - }catch (Exception e){ - log.warn("getSurveyFormId Fail"); - return null; - } - } - - public Survey getSurveyFromId(String surveyId){ - try{ - return surveyRepository.findSurveyBySurveyId(surveyId); - }catch (Exception e){ - log.warn("getSurveyForm survey Id Fail"); - return null; - } - } - - public List getQuestions(Survey survey){ - try{ - return questionRepository.findQuestionsBySurvey(survey); - }catch(Exception e){ - log.warn("getQuestionBySurvey failed..."); - return null; - } - } - - public Question getQuestion(Long id){ - try{ - return questionRepository.findQuestionById(id); - }catch (Exception e){ - log.warn("getQuestionById failed..."); - return null; - } - } - - public Question getQuestion(String id){ - try{ - return questionRepository.findQuestionByQuestionId(id); - }catch (Exception e){ - log.warn("getQuestionById failed..."); - return null; - } - } - - public Question getQuestion(Answer answer){ - try{ - return getQuestion(answer.getQuestionRealId()); - }catch (Exception e){ - log.warn("getQuestionByAnswer failed..."); - return null; - } - } - - public List getOptions(Survey survey){ - try{ - log.info("surveyId:{}",survey.getId()); - return questionOptionRepository.findQuestionOptionsBySurvey(survey); - }catch (Exception e){ - log.warn("getOptionsBy survey and question failed..."); - return null; - } - } - - public List getOptions(Answer answer){ - try{ - Question quesion = getQuestion(answer); - return questionOptionRepository.findQuestionOptionsByQuestion(quesion); - }catch (Exception e){ - log.warn("getOptionsBy survey and question failed..."); - return null; - } - } - - public Page getFilteredSurveysAll(Pageable pageable,SurveyRequest.LogFilterDto condition,SurveyFolder surveyFolder){ - if(condition.status()==null) condition = condition.setStatus(); - log.info("conditoin:{}",condition.toString()); - Page surveys = getSurveyFromCondition(pageable,condition,surveyFolder); - return surveys.map(survey -> { - LocalDate now = LocalDate.now(); - int daysLeft = (survey.getEndDate() == null) ? 0 : (int) ChronoUnit.DAYS.between(now, survey.getEndDate()); - boolean isBefore = survey.getStartDate() != null && now.isBefore(survey.getStartDate()) && !now.equals(survey.getStartDate()); - - return new SurveyResponseDto( - survey.getSurveyTitle(), - survey.getCreatedAt(), - survey.getParticipantCount(), - daysLeft, - survey.getSurveyId(), - isBefore, - survey.getStartDate(), - survey.getEndDate() - ); - }); - } - - public Page getFilteredSurveysAllIsActive(Pageable pageable,SurveyRequest.LogFilterDto condition,SurveyFolder surveyFolder){ - if (condition.status() == null) condition = condition.setStatus(); - Page surveys = getSurveyFromCondition(pageable, condition,surveyFolder); - - LocalDate now = LocalDate.now(); - - // stream 으로 바꿔서 종료된 설문 빼고 다시 PageImpl 로 감싸기 - List filtered = surveys.stream() - .filter(survey -> survey.getEndDate() == null || !survey.getEndDate().isBefore(now) && survey.isShare()) // 종료일이 오늘 이전이면 제외 - .map(survey -> { - int daysLeft = (survey.getEndDate() == null) ? 0 : (int) ChronoUnit.DAYS.between(now, survey.getEndDate()); - boolean isBefore = survey.getStartDate() != null && now.isBefore(survey.getStartDate()) && !now.equals(survey.getStartDate()); - - return new SurveyResponseDto( - survey.getSurveyTitle(), - survey.getCreatedAt(), - survey.getParticipantCount(), - daysLeft, - survey.getSurveyId(), - isBefore, - survey.getStartDate(), - survey.getEndDate() - ); - }) - .toList(); - - return new PageImpl<>(filtered, pageable, filtered.size()); - } - - public Page getFilteredSurveysActive(Pageable pageable,SurveyRequest.LogFilterDto condition,String folderId,boolean isAdmin){ - SurveyFolder surveyFolder=null; - try{ - surveyFolder = surveyFolderRepository.findByFolderId(folderId).orElseThrow( - () -> new BaseException(ErrorCode.BAD_REQUEST) - ); - }catch (Exception e){ - log.warn(e.getMessage()); - } - if(isAdmin) return getFilteredSurveysAll(pageable,condition,surveyFolder); - else return getFilteredSurveysAllIsActive(pageable,condition,null); - } - - - public List getAllSurveyFromDB(){ - try{ - return surveyRepository.findAll(); - }catch (Exception e){ - log.warn("get all survey failed..."); - return null; - } - } - - public Page getAllSurveyFromDB(Pageable pageable){ - try{ - return surveyRepository.findAll(pageable); - }catch (Exception e){ - log.warn("get all survey failed..."); - return null; - } - } - - public Page getSurveyFromCondition(Pageable pageable, SurveyRequest.LogFilterDto condition,SurveyFolder surveyFolder){ - try{ - return surveyRepository.findByFilter(condition.text(),condition.date(), condition.status(),condition.surveyType(),pageable,surveyFolder); - }catch (Exception e){ - log.warn("get survey by conditon failed..."); - return null; - } - } - - public List findAllSurveyLog(){ - try{ - return surveyLogRepository.findTop7ByOrderByCreatedAtDesc(); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public SurveyLog getSurveyLog(String logId){ - try{ - return surveyLogRepository.findSurveyLogBySurveyLogId(logId); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public List getSurveyLogs(List logIds){ - try{ - List response = new ArrayList<>(); - for(String id : logIds) response.add(getSurveyLog(id)); - if(response.isEmpty()) throw new NullPointerException(); - return response; - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public List getSurveyLogs(Survey survey){ - try{ - return surveyLogRepository.findSurveysLogBySurvey(survey); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public List getSurveyLogs(String surveyId){ - try{ - Survey survey = getSurveyFromId(surveyId); - return surveyLogRepository.findSurveysLogBySurvey(survey); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public Page getSurveyLogs(Member member,Pageable pageable){ - try{ - return surveyLogRepository.findSurveysLogByMember(member,pageable); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public Page getSurveyLogs(Survey survey,Pageable pageable){ - try{ - return surveyLogRepository.findSurveysLogBySurvey(survey,pageable); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public List getAnswer(SurveyLog surveyLog){ - try{ - return answerRepository.findAnswersBySurveyLog(surveyLog); - }catch (Exception e){ - log.warn(e.getMessage()); - return null; - } - } - - public String getAnswerFromMultiple(Answer answer){ - List options = getOptions(answer); - log.info("options:{}",options.toString()); - if(answer.getAnswerType().equals(QuestionType.MULTIPLECHECK)){ - List targetOptions = Arrays.stream(answer.getMultiAnswer().replaceAll("[\\[\\]]","").split(",\\s*")) - .map(s -> s.split("_",2)[1]) - .toList(); - return String.join(",",targetOptions); - } - else if(answer.getAnswerType().equals(QuestionType.MULTIPLE)){ - return answer.getMultiAnswer().split("_",2)[1]; - } - return answer.getAnswer(); - } - -} +package com.cooperation.project.cooperationcenter.domain.survey.service.homepage; + +import com.cooperation.project.cooperationcenter.domain.member.model.Member; +import com.cooperation.project.cooperationcenter.domain.survey.dto.SurveyRequest; +import com.cooperation.project.cooperationcenter.domain.survey.dto.SurveyResponseDto; +import com.cooperation.project.cooperationcenter.domain.survey.model.*; +import com.cooperation.project.cooperationcenter.domain.survey.repository.*; +import com.cooperation.project.cooperationcenter.global.exception.BaseException; +import com.cooperation.project.cooperationcenter.global.exception.codes.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SurveyFindService { + private final SurveyRepository surveyRepository; + private final QuestionOptionRepository questionOptionRepository; + private final QuestionRepository questionRepository; + private final SurveyLogRepository surveyLogRepository; + private final AnswerRepository answerRepository; + private final SurveyFolderRepository surveyFolderRepository; + + public Survey getSurveyFromId(Long surveyId) { + return requireNonNull(surveyRepository.findSurveyById(surveyId), ErrorCode.NOT_FOUND_ERROR); + } + + public Survey getSurveyFromId(String surveyId) { + return requireNonNull(surveyRepository.findSurveyBySurveyId(surveyId), ErrorCode.NOT_FOUND_ERROR); + } + + public List getQuestions(Survey survey) { + if (survey == null) { + throw new BaseException(ErrorCode.BAD_REQUEST_ERROR); + } + List questions = questionRepository.findQuestionsBySurvey(survey); + return questions == null ? Collections.emptyList() : questions; + } + + public Question getQuestion(Long id) { + return requireNonNull(questionRepository.findQuestionById(id), ErrorCode.NOT_FOUND_ERROR); + } + + public Question getQuestion(String id) { + return requireNonNull(questionRepository.findQuestionByQuestionId(id), ErrorCode.NOT_FOUND_ERROR); + } + + public Question getQuestion(Answer answer) { + if (answer == null) { + throw new BaseException(ErrorCode.BAD_REQUEST_ERROR); + } + return getQuestion(answer.getQuestionRealId()); + } + + public List getOptions(Survey survey) { + if (survey == null) { + throw new BaseException(ErrorCode.BAD_REQUEST_ERROR); + } + log.info("surveyId:{}", survey.getId()); + List options = questionOptionRepository.findQuestionOptionsBySurvey(survey); + return options == null ? Collections.emptyList() : options; + } + + public List getOptions(Answer answer) { + Question question = getQuestion(answer); + List options = questionOptionRepository.findQuestionOptionsByQuestion(question); + return options == null ? Collections.emptyList() : options; + } + + public Page getFilteredSurveysAll(Pageable pageable, SurveyRequest.LogFilterDto condition, SurveyFolder surveyFolder) { + if (condition.status() == null) condition = condition.setStatus(); + log.info("conditoin:{}", condition.toString()); + Page surveys = getSurveyFromCondition(pageable, condition, surveyFolder); + return surveys.map(survey -> { + LocalDate now = LocalDate.now(); + int daysLeft = (survey.getEndDate() == null) ? 0 : (int) ChronoUnit.DAYS.between(now, survey.getEndDate()); + boolean isBefore = survey.getStartDate() != null && now.isBefore(survey.getStartDate()) && !now.equals(survey.getStartDate()); + + return new SurveyResponseDto( + survey.getSurveyTitle(), + survey.getCreatedAt(), + survey.getParticipantCount(), + daysLeft, + survey.getSurveyId(), + isBefore, + survey.getStartDate(), + survey.getEndDate() + ); + }); + } + + public Page getFilteredSurveysAllIsActive(Pageable pageable, SurveyRequest.LogFilterDto condition, SurveyFolder surveyFolder) { + if (condition.status() == null) condition = condition.setStatus(); + Page surveys = getSurveyFromCondition(pageable, condition, surveyFolder); + + LocalDate now = LocalDate.now(); + + List filtered = surveys.stream() + .filter(survey -> survey.getEndDate() == null || !survey.getEndDate().isBefore(now) && survey.isShare()) + .map(survey -> { + int daysLeft = (survey.getEndDate() == null) ? 0 : (int) ChronoUnit.DAYS.between(now, survey.getEndDate()); + boolean isBefore = survey.getStartDate() != null && now.isBefore(survey.getStartDate()) && !now.equals(survey.getStartDate()); + + return new SurveyResponseDto( + survey.getSurveyTitle(), + survey.getCreatedAt(), + survey.getParticipantCount(), + daysLeft, + survey.getSurveyId(), + isBefore, + survey.getStartDate(), + survey.getEndDate() + ); + }) + .toList(); + + return new PageImpl<>(filtered, pageable, filtered.size()); + } + + public Page getFilteredSurveysActive(Pageable pageable, SurveyRequest.LogFilterDto condition, String folderId, boolean isAdmin) { + SurveyFolder surveyFolder = null; + if (isAdmin && folderId != null && !folderId.isBlank()) { + surveyFolder = surveyFolderRepository.findByFolderId(folderId) + .orElseThrow(() -> new BaseException(ErrorCode.BAD_REQUEST_ERROR)); + } + + if (isAdmin) return getFilteredSurveysAll(pageable, condition, surveyFolder); + else return getFilteredSurveysAllIsActive(pageable, condition, null); + } + + + public List getAllSurveyFromDB() { + List surveys = surveyRepository.findAll(); + return surveys == null ? Collections.emptyList() : surveys; + } + + public Page getAllSurveyFromDB(Pageable pageable) { + return surveyRepository.findAll(pageable); + } + + public Page getSurveyFromCondition(Pageable pageable, SurveyRequest.LogFilterDto condition, SurveyFolder surveyFolder) { + return surveyRepository.findByFilter(condition.text(), condition.date(), condition.status(), condition.surveyType(), pageable, surveyFolder); + } + + public List findAllSurveyLog() { + List logs = surveyLogRepository.findTop7ByOrderByCreatedAtDesc(); + return logs == null ? Collections.emptyList() : logs; + } + + public SurveyLog getSurveyLog(String logId) { + return requireNonNull(surveyLogRepository.findSurveyLogBySurveyLogId(logId), ErrorCode.NOT_FOUND_ERROR); + } + + public List getSurveyLogs(List logIds) { + List response = new ArrayList<>(); + for (String id : logIds) response.add(getSurveyLog(id)); + if (response.isEmpty()) throw new BaseException(ErrorCode.NOT_FOUND_ERROR); + return response; + } + + public List getSurveyLogs(Survey survey) { + if (survey == null) { + throw new BaseException(ErrorCode.BAD_REQUEST_ERROR); + } + List logs = surveyLogRepository.findSurveysLogBySurvey(survey); + return logs == null ? Collections.emptyList() : logs; + } + + public List getSurveyLogs(String surveyId) { + Survey survey = getSurveyFromId(surveyId); + List logs = surveyLogRepository.findSurveysLogBySurvey(survey); + return logs == null ? Collections.emptyList() : logs; + } + + public Page getSurveyLogs(Member member, Pageable pageable) { + if (member == null) { + throw new BaseException(ErrorCode.BAD_REQUEST_ERROR); + } + return surveyLogRepository.findSurveysLogByMember(member, pageable); + } + + public Page getSurveyLogs(Survey survey, Pageable pageable) { + if (survey == null) { + throw new BaseException(ErrorCode.BAD_REQUEST_ERROR); + } + return surveyLogRepository.findSurveysLogBySurvey(survey, pageable); + } + + public List getAnswer(SurveyLog surveyLog) { + if (surveyLog == null) { + throw new BaseException(ErrorCode.BAD_REQUEST_ERROR); + } + List answers = answerRepository.findAnswersBySurveyLog(surveyLog); + return answers == null ? Collections.emptyList() : answers; + } + + public String getAnswerFromMultiple(Answer answer) { + List options = getOptions(answer); + log.info("options:{}", options.toString()); + if (answer.getAnswerType().equals(QuestionType.MULTIPLECHECK)) { + List targetOptions = Arrays.stream(answer.getMultiAnswer().replaceAll("[\\[\\]]", "").split(",\\s*")) + .map(s -> s.split("_", 2)[1]) + .toList(); + return String.join(",", targetOptions); + } else if (answer.getAnswerType().equals(QuestionType.MULTIPLE)) { + return answer.getMultiAnswer().split("_", 2)[1]; + } + return answer.getAnswer(); + } + + private T requireNonNull(T value, ErrorCode errorCode) { + if (value == null) { + throw new BaseException(errorCode); + } + return value; + } +} \ No newline at end of file diff --git a/src/main/java/com/cooperation/project/cooperationcenter/global/config/SecurityConfig.java b/src/main/java/com/cooperation/project/cooperationcenter/global/config/SecurityConfig.java index 7cac7ce..25667e1 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/global/config/SecurityConfig.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/global/config/SecurityConfig.java @@ -46,11 +46,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ //note static 해제 .requestMatchers("/css/**","/plugins/**","/js/**").permitAll() //fixme 임시용임 밑에는 - .requestMatchers("/v3/**", - "/swagger-ui.html", - "/swagger-ui/**", - "/swagger-resources/**", - "/api-test/**").permitAll() + .requestMatchers("/v3/**", "/swagger-ui.html", "/swagger-ui/**", + "/swagger-resources/**", "/api-test/**","/api-test").permitAll() //note 일반 사용자 페이지 .requestMatchers("/","/home", "/member/signup","/member/login", @@ -60,8 +57,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ .requestMatchers("/check/**","/member/password/**","/api/v1/member/reset/**").permitAll() .requestMatchers("/api/v1/tencent/**","/api/v1/agency/region").permitAll() .requestMatchers("/api/v1/admin/login").permitAll() - - + .requestMatchers("/actuator/**","/actuator","/error").permitAll() //note 로그인한 사용자 .requestMatchers("/survey/log/detail/**").authenticated() diff --git a/src/main/java/com/cooperation/project/cooperationcenter/global/exception/GlobalExceptionHandler.java b/src/main/java/com/cooperation/project/cooperationcenter/global/exception/GlobalExceptionHandler.java index b6d5496..dfb4068 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/global/exception/GlobalExceptionHandler.java @@ -1,40 +1,32 @@ -package com.cooperation.project.cooperationcenter.global.exception; - -import com.cooperation.project.cooperationcenter.global.exception.codes.ErrorCode; -import com.cooperation.project.cooperationcenter.global.exception.codes.reason.Reason; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -@RestControllerAdvice -@Slf4j -public class GlobalExceptionHandler { - +//package com.cooperation.project.cooperationcenter.global.exception; +// +//import com.cooperation.project.cooperationcenter.global.exception.codes.ErrorCode; +//import com.cooperation.project.cooperationcenter.global.exception.codes.reason.Reason; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.http.HttpStatus; +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.ExceptionHandler; +//import org.springframework.web.bind.annotation.RestControllerAdvice; +// +//@RestControllerAdvice +//@Slf4j +//public class GlobalExceptionHandler { +// // @ExceptionHandler(BaseException.class) -// public ResponseEntity> handleBaseException(BaseException e) { +// public BaseResponse handleBaseException(BaseException e) { // Reason.ReasonDto reason = e.getErrorReasonHttpStatus(); // log.info("baseResponse:{}",BaseResponse.onFailure(reason.getCode(), reason.getMessage(), null).toString()); +// return BaseResponse.onFailure(reason.getCode(), reason.getMessage(), null); +// } +// +// @ExceptionHandler(Exception.class) +// public ResponseEntity> handleOther(Exception ex) { // return ResponseEntity -// .status(reason.getHttpStatus()) -// .body(BaseResponse.onFailure(reason.getCode(), reason.getMessage(), null)); +// .status(HttpStatus.INTERNAL_SERVER_ERROR) +// .body(BaseResponse.onFailure( +// ErrorCode.INTERNAL_SERVER_ERROR.getCode(), +// ErrorCode.INTERNAL_SERVER_ERROR.getMessage(), +// null +// )); // } - @ExceptionHandler(BaseException.class) - public BaseResponse handleBaseException(BaseException e) { - Reason.ReasonDto reason = e.getErrorReasonHttpStatus(); - log.info("baseResponse:{}",BaseResponse.onFailure(reason.getCode(), reason.getMessage(), null).toString()); - return BaseResponse.onFailure(reason.getCode(), reason.getMessage(), null); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> handleOther(Exception ex) { - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(BaseResponse.onFailure( - ErrorCode.INTERNAL_SERVER_ERROR.getCode(), - ErrorCode.INTERNAL_SERVER_ERROR.getMessage(), - null - )); - } -} +//} diff --git a/src/main/java/com/cooperation/project/cooperationcenter/global/exception/codes/ErrorCode.java b/src/main/java/com/cooperation/project/cooperationcenter/global/exception/codes/ErrorCode.java index bd650ba..78d4c79 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/global/exception/codes/ErrorCode.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/global/exception/codes/ErrorCode.java @@ -76,9 +76,6 @@ public enum ErrorCode implements BaseCode { FILE_SIZE_ERROR(HttpStatus.BAD_REQUEST, "FILE-0004", "파일 사이즈가 너무 큽니다."), //로그인 에러 - EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "LOGIN-0000", "이메일이 잘못됨"), - PASSWORD_ERROR(HttpStatus.BAD_REQUEST, "LOGIN-0001", "잘못된 비밀번호입니다."), - BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON-0000", "잘못된 요청입니다."), EXIST_EMAIL(HttpStatus.BAD_REQUEST, "COMMON-0002", "이미 존재하는 회원입니다."), @@ -88,19 +85,10 @@ public enum ErrorCode implements BaseCode { EMPTY_TOKEN_PROVIDED(HttpStatus.UNAUTHORIZED, "TOKEN-0002", "토큰 텅텅"), REFRESH_TOKEN_NOT_VALID(HttpStatus.UNAUTHORIZED, "TOKEN-0003", "리프레시 토큰이 올바르지 않음"), - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-0000", "존재하지 않는 회원입니다."), - MEMBER_SAVE_ERROR(HttpStatus.BAD_REQUEST,"MEMBER-0001","member save error"), - MEMBER_ALREADY_EXIST(HttpStatus.BAD_REQUEST,"MEMBER-0002","member already exist"), - MEMBER_NOT_ACCEPTED(HttpStatus.BAD_REQUEST,"MEMBER-0003","아직 계정이 활성화되지 않았습니다."), - MEMBER_NOT_ADMIN(HttpStatus.BAD_REQUEST,"MEMBER-0004","Meber is not ADMIN"), - MEMBER_ALREADY_ACCEPTED_EMAIL(HttpStatus.BAD_REQUEST,"MEMBER-0005","해당 이메일로 승인된 아이디가 존재합니다."), - - COLLEGE_NOT_FOUND(HttpStatus.BAD_REQUEST,"COLLEGE-0001","해당 학과를 찾을 수가 없습니다."), - SURVEY_DATE_NOT_VALID(HttpStatus.BAD_REQUEST,"SURVEY-0000","지금 설문조사 입력 기간이 아닙니다."), SURVEY_NOT_SHARE(HttpStatus.BAD_REQUEST,"SURVEY-0001","해당 설문조사는 공개 전입니다."), - AGENCY_NOT_FOUND(HttpStatus.BAD_REQUEST,"AGENCY-0001","해당 유학원은 가입 되지 않은 상태입니다. 확인 후에 다시 가입해주세요"), + // 5xx : server error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER-0000", "서버 에러"); diff --git a/src/main/java/com/cooperation/project/cooperationcenter/global/filter/AuthenticationTokenFilter.java b/src/main/java/com/cooperation/project/cooperationcenter/global/filter/AuthenticationTokenFilter.java index 3a1819c..bef7582 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/global/filter/AuthenticationTokenFilter.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/global/filter/AuthenticationTokenFilter.java @@ -49,8 +49,7 @@ protected void doFilterInternal(HttpServletRequest request, //note 무시하는 endpoint들 final String[] IGNORE_PATHS = { "/css", "/js", "/plugins","/member/logout","/member/signup","/api/v1/member","api/v1/school", - "/api/v1/admin","/api/v1/file/img","/admin/login", "/static/favicon.ico","/api/v1/tencent","/favicon.ico","/api/v1/agency" -// ,"/member/login" + "/api/v1/admin","/api/v1/file/img","/admin/login", "/static/favicon.ico","/api/v1/tencent","/favicon.ico","/api/v1/agency","/actuator" }; for (String allowed : IGNORE_PATHS) { diff --git a/src/main/java/com/cooperation/project/cooperationcenter/global/filter/CustomAuthenticationEntryPoint.java b/src/main/java/com/cooperation/project/cooperationcenter/global/filter/CustomAuthenticationEntryPoint.java index afbced4..4f88cd9 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/global/filter/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/global/filter/CustomAuthenticationEntryPoint.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; @@ -9,6 +10,7 @@ import java.io.IOException; @Component +@Slf4j public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { //Authentication예외 상황일 때 로그인 페이지로 보내기 위함. @@ -18,7 +20,7 @@ public void commence(HttpServletRequest request, AuthenticationException authException) throws IOException { String uri = request.getRequestURI(); - + log.info("custom Authen path:{}",uri); if (uri.startsWith("/admin")) { response.sendRedirect("/admin/login"); } else{ diff --git a/src/main/java/com/cooperation/project/cooperationcenter/global/filter/GlobalModelAttributeAdvice.java b/src/main/java/com/cooperation/project/cooperationcenter/global/filter/GlobalModelAttributeAdvice.java index f74a060..69c87ed 100644 --- a/src/main/java/com/cooperation/project/cooperationcenter/global/filter/GlobalModelAttributeAdvice.java +++ b/src/main/java/com/cooperation/project/cooperationcenter/global/filter/GlobalModelAttributeAdvice.java @@ -2,6 +2,8 @@ import com.cooperation.project.cooperationcenter.domain.member.dto.MemberDetails; import com.cooperation.project.cooperationcenter.domain.member.dto.MemberResponse; +import com.cooperation.project.cooperationcenter.domain.member.exception.MemberHandler; +import com.cooperation.project.cooperationcenter.domain.member.exception.status.MemberErrorStatus; import com.cooperation.project.cooperationcenter.domain.member.model.Member; import com.cooperation.project.cooperationcenter.domain.member.repository.MemberRepository; import com.cooperation.project.cooperationcenter.domain.school.dto.SchoolResponse; @@ -43,10 +45,10 @@ public MemberResponse.LoginDto addLoginMember(HttpServletRequest request,@Authen if (memberDetails == null || uri.startsWith("/api/")) return null; String email = memberDetails.getUsername(); Member member = memberRepository.findMemberByEmail(email).orElseThrow( - ()->new BaseException(ErrorCode.MEMBER_NOT_FOUND) + ()->new MemberHandler(MemberErrorStatus.MEMBER_NOT_FOUND) ); if(!member.isAccept()) { - throw new BaseException(ErrorCode.MEMBER_NOT_ACCEPTED); + throw new MemberHandler(MemberErrorStatus.MEMBER_NOT_ACCEPTED); } return MemberResponse.LoginDto.from(member); // null일 수 있음 } @@ -58,7 +60,7 @@ public boolean addTokenExpiredFlag(HttpServletRequest request) { } @ModelAttribute("schoolCategory") - public List loadSchoolCatogory() { + public List loadSchoolCategory() { return schoolFindService.loadAllSchoolByDto(); } @@ -76,6 +78,7 @@ public List loadSchoolBoards(HttpServletRequest r if (vars != null && vars.get("boardId") != null) { nowId = Long.valueOf(vars.get("boardId")); } + log.info("찾으려는 대학교:{}",englishName); // String englishName = request.getRequestURI().split("/")[2]; School school = schoolFindService.loadSchoolByEnglishName(englishName); return schoolFindService.loadBoardBySchoolByDto(school,nowId); diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..1ad0bbf --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,20 @@ + + + + + + + + + + ${PATTERN} + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/static/js/css/output.css b/src/main/resources/static/js/css/output.css index 555c356..877071b 100644 --- a/src/main/resources/static/js/css/output.css +++ b/src/main/resources/static/js/css/output.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{top:0;bottom:0}.bottom-0{bottom:0}.bottom-4{bottom:1rem}.bottom-full{bottom:100%}.left-0{left:0}.left-1\/2{left:50%}.left-3{left:.75rem}.left-4{left:1rem}.left-5{left:1.25rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-20{top:5rem}.top-4{top:1rem}.top-full{top:100%}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.z-\[9998\]{z-index:9998}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.float-right{float:right}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-6{margin-left:1.5rem}.ml-7{margin-left:1.75rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-0\.5{margin-right:.125rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mr-4{margin-right:1rem}.mr-6{margin-right:1.5rem}.mr-8{margin-right:2rem}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-row{display:table-row}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-\[4\/3\]{aspect-ratio:4/3}.size-\[42px\]{width:42px;height:42px}.size-full{width:100%;height:100%}.\!h-20{height:5rem!important}.\!h-28{height:7rem!important}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-20{height:5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-32{height:8rem}.h-36{height:9rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[400px\]{height:400px}.h-\[480px\]{height:480px}.h-auto{height:auto}.h-full{height:100%}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-80{max-height:20rem}.max-h-\[50vh\]{max-height:50vh}.max-h-\[80vh\]{max-height:80vh}.max-h-\[90vh\]{max-height:90vh}.max-h-full{max-height:100%}.min-h-\[200px\]{min-height:200px}.min-h-\[300px\]{min-height:300px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-32{width:8rem}.w-4{width:1rem}.w-40{width:10rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-64{width:16rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-96{width:24rem}.w-\[320px\]{width:320px}.w-\[360px\]{width:360px}.w-\[400px\]{width:400px}.w-\[480px\]{width:480px}.w-\[560px\]{width:560px}.w-\[600px\]{width:600px}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[120px\]{min-width:120px}.min-w-\[150px\]{min-width:150px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.max-w-\[120px\]{max-width:120px}.max-w-\[140px\]{max-width:140px}.max-w-\[42ch\]{max-width:42ch}.max-w-\[90px\]{max-width:90px}.max-w-\[90vw\]{max-width:90vw}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.border-collapse{border-collapse:collapse}.origin-top-left{transform-origin:top left}.origin-top-right{transform-origin:top right}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-12,.-translate-x-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-12{--tw-translate-x:-3rem}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-16,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-16{--tw-translate-y:-4rem}.translate-x-16{--tw-translate-x:4rem}.translate-x-16,.translate-x-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x:1.25rem}.translate-y-12{--tw-translate-y:3rem}.translate-y-12,.translate-y-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-full{--tw-translate-y:100%}.rotate-180{--tw-rotate:180deg}.rotate-180,.scale-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-0{--tw-scale-x:0;--tw-scale-y:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-none{resize:none}.resize{resize:both}.list-disc{list-style-type:disc}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-6>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.5rem*var(--tw-space-x-reverse));margin-left:calc(1.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-10>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2.5rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.divide-gray-700>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(55 65 81/var(--tw-divide-opacity,1))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.\!rounded-2xl{border-radius:24px!important}.\!rounded-button{border-radius:8px!important}.rounded{border-radius:8px}.rounded-2xl{border-radius:24px}.rounded-3xl{border-radius:32px}.rounded-button{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:16px}.rounded-md{border-radius:12px}.rounded-sm{border-radius:4px}.rounded-xl{border-radius:20px}.rounded-l-md{border-top-left-radius:12px;border-bottom-left-radius:12px}.rounded-r-md{border-top-right-radius:12px;border-bottom-right-radius:12px}.rounded-t-lg{border-top-left-radius:16px;border-top-right-radius:16px}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-blue-600{--tw-border-opacity:1;border-color:rgb(37 99 235/var(--tw-border-opacity,1))}.border-emerald-200{--tw-border-opacity:1;border-color:rgb(167 243 208/var(--tw-border-opacity,1))}.border-fuchsia-200{--tw-border-opacity:1;border-color:rgb(245 208 254/var(--tw-border-opacity,1))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity,1))}.border-green-400{--tw-border-opacity:1;border-color:rgb(74 222 128/var(--tw-border-opacity,1))}.border-indigo-200{--tw-border-opacity:1;border-color:rgb(199 210 254/var(--tw-border-opacity,1))}.border-indigo-600{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.border-pink-200{--tw-border-opacity:1;border-color:rgb(251 207 232/var(--tw-border-opacity,1))}.border-primary{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.border-primary\/30{border-color:rgba(79,70,229,.3)}.border-red-400{--tw-border-opacity:1;border-color:rgb(248 113 113/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-rose-200{--tw-border-opacity:1;border-color:rgb(254 205 211/var(--tw-border-opacity,1))}.border-sky-200{--tw-border-opacity:1;border-color:rgb(186 230 253/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-violet-200{--tw-border-opacity:1;border-color:rgb(221 214 254/var(--tw-border-opacity,1))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-white\/20{border-color:hsla(0,0%,100%,.2)}.border-yellow-400{--tw-border-opacity:1;border-color:rgb(250 204 21/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-200{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/30{background-color:rgba(0,0,0,.3)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-black\/60{background-color:rgba(0,0,0,.6)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-emerald-50{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.bg-fuchsia-50{--tw-bg-opacity:1;background-color:rgb(253 244 255/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-gray-900\/70{background-color:rgba(17,24,39,.7)}.bg-gray-950\/80{background-color:rgba(3,7,18,.8)}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-400{--tw-bg-opacity:1;background-color:rgb(74 222 128/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-indigo-200{--tw-bg-opacity:1;background-color:rgb(199 210 254/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-500{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity,1))}.bg-orange-100{--tw-bg-opacity:1;background-color:rgb(255 237 213/var(--tw-bg-opacity,1))}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-pink-100{--tw-bg-opacity:1;background-color:rgb(252 231 243/var(--tw-bg-opacity,1))}.bg-pink-50{--tw-bg-opacity:1;background-color:rgb(253 242 248/var(--tw-bg-opacity,1))}.bg-primary{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-primary\/10{background-color:rgba(79,70,229,.1)}.bg-primary\/20{background-color:rgba(79,70,229,.2)}.bg-primary\/5{background-color:rgba(79,70,229,.05)}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-900\/20{background-color:rgba(127,29,29,.2)}.bg-rose-50{--tw-bg-opacity:1;background-color:rgb(255 241 242/var(--tw-bg-opacity,1))}.bg-secondary\/10{background-color:rgba(129,140,248,.1)}.bg-secondary\/20{background-color:rgba(129,140,248,.2)}.bg-sky-50{--tw-bg-opacity:1;background-color:rgb(240 249 255/var(--tw-bg-opacity,1))}.bg-teal-500{--tw-bg-opacity:1;background-color:rgb(20 184 166/var(--tw-bg-opacity,1))}.bg-violet-50{--tw-bg-opacity:1;background-color:rgb(245 243 255/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/70{background-color:hsla(0,0%,100%,.7)}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-400{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-20{--tw-bg-opacity:0.2}.bg-opacity-5{--tw-bg-opacity:0.05}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-blue-100{--tw-gradient-from:#dbeafe var(--tw-gradient-from-position);--tw-gradient-to:rgba(219,234,254,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-50{--tw-gradient-from:#eff6ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(239,246,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900\/80{--tw-gradient-from:rgba(17,24,39,.8) var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-indigo-100{--tw-gradient-from:#e0e7ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(224,231,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:#4f46e5 var(--tw-gradient-from-position);--tw-gradient-to:rgba(79,70,229,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-100{--tw-gradient-from:#fee2e2 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,93%,94%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-slate-50{--tw-gradient-from:#f8fafc var(--tw-gradient-from-position);--tw-gradient-to:rgba(248,250,252,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-white{--tw-gradient-from:#fff var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,0%,100%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.via-slate-800{--tw-gradient-to:rgba(30,41,59,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),#1e293b var(--tw-gradient-via-position),var(--tw-gradient-to)}.via-white\/90{--tw-gradient-to:hsla(0,0%,100%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),hsla(0,0%,100%,.9) var(--tw-gradient-via-position),var(--tw-gradient-to)}.to-blue-200{--tw-gradient-to:#bfdbfe var(--tw-gradient-to-position)}.to-blue-50{--tw-gradient-to:#eff6ff var(--tw-gradient-to-position)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-indigo-100{--tw-gradient-to:#e0e7ff var(--tw-gradient-to-position)}.to-indigo-200{--tw-gradient-to:#c7d2fe var(--tw-gradient-to-position)}.to-orange-100{--tw-gradient-to:#ffedd5 var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:#818cf8 var(--tw-gradient-to-position)}.to-white\/10{--tw-gradient-to:hsla(0,0%,100%,.1) var(--tw-gradient-to-position)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.object-center{-o-object-position:center;object-position:center}.object-top{-o-object-position:top;object-position:top}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-12{padding:3rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.\!py-2\.5{padding-top:.625rem!important;padding-bottom:.625rem!important}.px-0{padding-left:0;padding-right:0}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pb-6{padding-bottom:1.5rem}.pl-10{padding-left:2.5rem}.pl-3{padding-left:.75rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pr-10{padding-right:2.5rem}.pr-12{padding-right:3rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-16{padding-top:4rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-\[\'Pacifico\'\]{font-family:Pacifico}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-8xl{font-size:6rem;line-height:1}.text-9xl{font-size:8rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-5{line-height:1.25rem}.leading-\[1\.2\]{line-height:1.2}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-fuchsia-700{--tw-text-opacity:1;color:rgb(162 28 175/var(--tw-text-opacity,1))}.text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-500{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-orange-400{--tw-text-opacity:1;color:rgb(251 146 60/var(--tw-text-opacity,1))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity,1))}.text-pink-600{--tw-text-opacity:1;color:rgb(219 39 119/var(--tw-text-opacity,1))}.text-pink-700{--tw-text-opacity:1;color:rgb(190 24 93/var(--tw-text-opacity,1))}.text-pink-800{--tw-text-opacity:1;color:rgb(157 23 77/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-purple-400{--tw-text-opacity:1;color:rgb(192 132 252/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-purple-800{--tw-text-opacity:1;color:rgb(107 33 168/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-rose-700{--tw-text-opacity:1;color:rgb(190 18 60/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.text-sky-700{--tw-text-opacity:1;color:rgb(3 105 161/var(--tw-text-opacity,1))}.text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.text-violet-700{--tw-text-opacity:1;color:rgb(109 40 217/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.accent-primary{accent-color:#4f46e5}.opacity-0{opacity:0}.opacity-10{opacity:.1}.opacity-100{opacity:1}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.shadow-primary\/30{--tw-shadow-color:rgba(79,70,229,.3);--tw-shadow:var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-black{--tw-ring-opacity:1;--tw-ring-color:rgb(0 0 0/var(--tw-ring-opacity,1))}.ring-opacity-5{--tw-ring-opacity:0.05}.blur{--tw-blur:blur(8px)}.blur,.blur-3xl{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur:blur(64px)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.last\:border-0:last-child{border-width:0}.last\:border-b-0:last-child{border-bottom-width:0}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:-translate-y-1:hover,.hover\:scale-105:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.hover\:bg-blue-200:hover{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity,1))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-500:hover{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.hover\:bg-blue-600:hover{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-gray-800\/70:hover{background-color:rgba(31,41,55,.7)}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.hover\:bg-indigo-200:hover{--tw-bg-opacity:1;background-color:rgb(199 210 254/var(--tw-bg-opacity,1))}.hover\:bg-indigo-50:hover{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.hover\:bg-indigo-800:hover{--tw-bg-opacity:1;background-color:rgb(55 48 163/var(--tw-bg-opacity,1))}.hover\:bg-primary\/10:hover{background-color:rgba(79,70,229,.1)}.hover\:bg-primary\/90:hover{background-color:rgba(79,70,229,.9)}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-500:hover{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:text-gray-300:hover{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.hover\:text-gray-500:hover{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-indigo-800:hover{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.hover\:text-primary\/80:hover{color:rgba(79,70,229,.8)}.hover\:text-red-300:hover{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.hover\:text-red-400:hover{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.hover\:text-red-500:hover{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-lg:hover,.hover\:shadow-md:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-gray-700:focus{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-0:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-0:focus,.focus\:ring-1:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-inset:focus{--tw-ring-inset:inset}.focus\:ring-primary:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity,1))}.focus\:ring-primary\/40:focus{--tw-ring-color:rgba(79,70,229,.4)}.focus\:ring-primary\/50:focus{--tw-ring-color:rgba(79,70,229,.5)}.focus\:ring-opacity-50:focus{--tw-ring-opacity:0.5}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-primary\/40:focus-visible{--tw-ring-color:rgba(79,70,229,.4)}.disabled\:opacity-40:disabled{opacity:.4}.group:focus-within .group-focus-within\:visible{visibility:visible}.group:focus-within .group-focus-within\:rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:focus-within .group-focus-within\:opacity-100{opacity:1}.group:hover .group-hover\:visible{visibility:visible}.group:hover .group-hover\:rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:border-primary{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.peer:checked~.peer-checked\:bg-primary{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.peer:checked~.peer-checked\:opacity-100{opacity:1}@media (min-width:640px){.sm\:relative{position:relative}.sm\:left-auto{left:auto}.sm\:right-auto{right:auto}.sm\:top-auto{top:auto}.sm\:ml-10{margin-left:2.5rem}.sm\:ml-auto{margin-left:auto}.sm\:mt-0{margin-top:0}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:h-16{height:4rem}.sm\:h-auto{height:auto}.sm\:w-16{width:4rem}.sm\:w-auto{width:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-end{justify-content:flex-end}.sm\:gap-4{gap:1rem}.sm\:gap-6{gap:1.5rem}.sm\:space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.sm\:p-6{padding:1.5rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-base{font-size:1rem;line-height:1.5rem}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:768px){.md\:sticky{position:sticky}.md\:top-\[calc\(var\(--header-h\)\+16px\)\]{top:calc(var(--header-h) + 16px)}.md\:col-span-2{grid-column:span 2/span 2}.md\:ml-3{margin-left:.75rem}.md\:w-1\/2{width:50%}.md\:w-44{width:11rem}.md\:w-64{width:16rem}.md\:w-80{width:20rem}.md\:flex-1{flex:1 1 0%}.md\:shrink-0{flex-shrink:0}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-end{justify-content:flex-end}.md\:justify-between{justify-content:space-between}.md\:gap-4{gap:1rem}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.md\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.md\:self-start{align-self:flex-start}.md\:p-4{padding:1rem}.md\:p-5{padding:1.25rem}.md\:p-8{padding:2rem}.md\:px-6{padding-left:1.5rem;padding-right:1.5rem}.md\:px-8{padding-left:2rem;padding-right:2rem}.md\:pt-20{padding-top:5rem}.md\:text-3xl{font-size:1.875rem;line-height:2.25rem}.md\:text-base{font-size:1rem;line-height:1.5rem}.md\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{top:0;bottom:0}.bottom-0{bottom:0}.bottom-4{bottom:1rem}.bottom-full{bottom:100%}.left-0{left:0}.left-1\/2{left:50%}.left-3{left:.75rem}.left-4{left:1rem}.left-5{left:1.25rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-20{top:5rem}.top-4{top:1rem}.top-full{top:100%}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.z-\[9998\]{z-index:9998}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.float-right{float:right}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-6{margin-left:1.5rem}.ml-7{margin-left:1.75rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-0\.5{margin-right:.125rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mr-4{margin-right:1rem}.mr-6{margin-right:1.5rem}.mr-8{margin-right:2rem}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-row{display:table-row}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-\[4\/3\]{aspect-ratio:4/3}.size-\[42px\]{width:42px;height:42px}.size-full{width:100%;height:100%}.\!h-28{height:7rem!important}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-20{height:5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-32{height:8rem}.h-36{height:9rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[400px\]{height:400px}.h-\[480px\]{height:480px}.h-auto{height:auto}.h-full{height:100%}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-80{max-height:20rem}.max-h-\[50vh\]{max-height:50vh}.max-h-\[80vh\]{max-height:80vh}.max-h-\[90vh\]{max-height:90vh}.max-h-full{max-height:100%}.min-h-\[200px\]{min-height:200px}.min-h-\[300px\]{min-height:300px}.min-h-\[64px\]{min-height:64px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-32{width:8rem}.w-4{width:1rem}.w-40{width:10rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-64{width:16rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-96{width:24rem}.w-\[320px\]{width:320px}.w-\[360px\]{width:360px}.w-\[400px\]{width:400px}.w-\[480px\]{width:480px}.w-\[560px\]{width:560px}.w-\[600px\]{width:600px}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[120px\]{min-width:120px}.min-w-\[150px\]{min-width:150px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.max-w-\[120px\]{max-width:120px}.max-w-\[140px\]{max-width:140px}.max-w-\[42ch\]{max-width:42ch}.max-w-\[90px\]{max-width:90px}.max-w-\[90vw\]{max-width:90vw}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.border-collapse{border-collapse:collapse}.origin-top-left{transform-origin:top left}.origin-top-right{transform-origin:top right}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-12,.-translate-x-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-12{--tw-translate-x:-3rem}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-16,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-16{--tw-translate-y:-4rem}.translate-x-16{--tw-translate-x:4rem}.translate-x-16,.translate-x-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x:1.25rem}.translate-y-12{--tw-translate-y:3rem}.translate-y-12,.translate-y-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-full{--tw-translate-y:100%}.rotate-180{--tw-rotate:180deg}.rotate-180,.scale-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-0{--tw-scale-x:0;--tw-scale-y:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-none{resize:none}.resize{resize:both}.list-disc{list-style-type:disc}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-6>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.5rem*var(--tw-space-x-reverse));margin-left:calc(1.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-10>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2.5rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.divide-gray-700>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(55 65 81/var(--tw-divide-opacity,1))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.\!rounded-2xl{border-radius:24px!important}.\!rounded-button{border-radius:8px!important}.rounded{border-radius:8px}.rounded-2xl{border-radius:24px}.rounded-3xl{border-radius:32px}.rounded-button{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:16px}.rounded-md{border-radius:12px}.rounded-sm{border-radius:4px}.rounded-xl{border-radius:20px}.rounded-l-md{border-top-left-radius:12px;border-bottom-left-radius:12px}.rounded-r-md{border-top-right-radius:12px;border-bottom-right-radius:12px}.rounded-t-lg{border-top-left-radius:16px;border-top-right-radius:16px}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-blue-600{--tw-border-opacity:1;border-color:rgb(37 99 235/var(--tw-border-opacity,1))}.border-emerald-200{--tw-border-opacity:1;border-color:rgb(167 243 208/var(--tw-border-opacity,1))}.border-fuchsia-200{--tw-border-opacity:1;border-color:rgb(245 208 254/var(--tw-border-opacity,1))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity,1))}.border-green-400{--tw-border-opacity:1;border-color:rgb(74 222 128/var(--tw-border-opacity,1))}.border-indigo-200{--tw-border-opacity:1;border-color:rgb(199 210 254/var(--tw-border-opacity,1))}.border-indigo-600{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.border-pink-200{--tw-border-opacity:1;border-color:rgb(251 207 232/var(--tw-border-opacity,1))}.border-primary{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.border-primary\/30{border-color:rgba(79,70,229,.3)}.border-red-400{--tw-border-opacity:1;border-color:rgb(248 113 113/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-rose-200{--tw-border-opacity:1;border-color:rgb(254 205 211/var(--tw-border-opacity,1))}.border-sky-200{--tw-border-opacity:1;border-color:rgb(186 230 253/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-violet-200{--tw-border-opacity:1;border-color:rgb(221 214 254/var(--tw-border-opacity,1))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-white\/20{border-color:hsla(0,0%,100%,.2)}.border-yellow-400{--tw-border-opacity:1;border-color:rgb(250 204 21/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-200{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/30{background-color:rgba(0,0,0,.3)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-black\/60{background-color:rgba(0,0,0,.6)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-emerald-50{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.bg-fuchsia-50{--tw-bg-opacity:1;background-color:rgb(253 244 255/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-gray-900\/70{background-color:rgba(17,24,39,.7)}.bg-gray-950\/80{background-color:rgba(3,7,18,.8)}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-400{--tw-bg-opacity:1;background-color:rgb(74 222 128/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-indigo-200{--tw-bg-opacity:1;background-color:rgb(199 210 254/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-500{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity,1))}.bg-orange-100{--tw-bg-opacity:1;background-color:rgb(255 237 213/var(--tw-bg-opacity,1))}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-pink-100{--tw-bg-opacity:1;background-color:rgb(252 231 243/var(--tw-bg-opacity,1))}.bg-pink-50{--tw-bg-opacity:1;background-color:rgb(253 242 248/var(--tw-bg-opacity,1))}.bg-primary{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-primary\/10{background-color:rgba(79,70,229,.1)}.bg-primary\/20{background-color:rgba(79,70,229,.2)}.bg-primary\/5{background-color:rgba(79,70,229,.05)}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-900\/20{background-color:rgba(127,29,29,.2)}.bg-rose-50{--tw-bg-opacity:1;background-color:rgb(255 241 242/var(--tw-bg-opacity,1))}.bg-secondary\/10{background-color:rgba(129,140,248,.1)}.bg-secondary\/20{background-color:rgba(129,140,248,.2)}.bg-sky-50{--tw-bg-opacity:1;background-color:rgb(240 249 255/var(--tw-bg-opacity,1))}.bg-teal-500{--tw-bg-opacity:1;background-color:rgb(20 184 166/var(--tw-bg-opacity,1))}.bg-violet-50{--tw-bg-opacity:1;background-color:rgb(245 243 255/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/70{background-color:hsla(0,0%,100%,.7)}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-400{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-20{--tw-bg-opacity:0.2}.bg-opacity-5{--tw-bg-opacity:0.05}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-blue-100{--tw-gradient-from:#dbeafe var(--tw-gradient-from-position);--tw-gradient-to:rgba(219,234,254,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-50{--tw-gradient-from:#eff6ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(239,246,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900\/80{--tw-gradient-from:rgba(17,24,39,.8) var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-indigo-100{--tw-gradient-from:#e0e7ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(224,231,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:#4f46e5 var(--tw-gradient-from-position);--tw-gradient-to:rgba(79,70,229,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-100{--tw-gradient-from:#fee2e2 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,93%,94%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-slate-50{--tw-gradient-from:#f8fafc var(--tw-gradient-from-position);--tw-gradient-to:rgba(248,250,252,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-white{--tw-gradient-from:#fff var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,0%,100%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.via-slate-800{--tw-gradient-to:rgba(30,41,59,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),#1e293b var(--tw-gradient-via-position),var(--tw-gradient-to)}.via-white\/90{--tw-gradient-to:hsla(0,0%,100%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),hsla(0,0%,100%,.9) var(--tw-gradient-via-position),var(--tw-gradient-to)}.to-blue-200{--tw-gradient-to:#bfdbfe var(--tw-gradient-to-position)}.to-blue-50{--tw-gradient-to:#eff6ff var(--tw-gradient-to-position)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-indigo-100{--tw-gradient-to:#e0e7ff var(--tw-gradient-to-position)}.to-indigo-200{--tw-gradient-to:#c7d2fe var(--tw-gradient-to-position)}.to-orange-100{--tw-gradient-to:#ffedd5 var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:#818cf8 var(--tw-gradient-to-position)}.to-white\/10{--tw-gradient-to:hsla(0,0%,100%,.1) var(--tw-gradient-to-position)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.object-center{-o-object-position:center;object-position:center}.object-top{-o-object-position:top;object-position:top}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-12{padding:3rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.\!py-2\.5{padding-top:.625rem!important;padding-bottom:.625rem!important}.px-0{padding-left:0;padding-right:0}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pb-6{padding-bottom:1.5rem}.pl-10{padding-left:2.5rem}.pl-3{padding-left:.75rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pr-10{padding-right:2.5rem}.pr-12{padding-right:3rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-16{padding-top:4rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-\[\'Pacifico\'\]{font-family:Pacifico}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-8xl{font-size:6rem;line-height:1}.text-9xl{font-size:8rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-5{line-height:1.25rem}.leading-\[1\.2\]{line-height:1.2}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-fuchsia-700{--tw-text-opacity:1;color:rgb(162 28 175/var(--tw-text-opacity,1))}.text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-500{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-orange-400{--tw-text-opacity:1;color:rgb(251 146 60/var(--tw-text-opacity,1))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity,1))}.text-pink-600{--tw-text-opacity:1;color:rgb(219 39 119/var(--tw-text-opacity,1))}.text-pink-700{--tw-text-opacity:1;color:rgb(190 24 93/var(--tw-text-opacity,1))}.text-pink-800{--tw-text-opacity:1;color:rgb(157 23 77/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-purple-400{--tw-text-opacity:1;color:rgb(192 132 252/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-purple-800{--tw-text-opacity:1;color:rgb(107 33 168/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-rose-700{--tw-text-opacity:1;color:rgb(190 18 60/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.text-sky-700{--tw-text-opacity:1;color:rgb(3 105 161/var(--tw-text-opacity,1))}.text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.text-violet-700{--tw-text-opacity:1;color:rgb(109 40 217/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.accent-primary{accent-color:#4f46e5}.opacity-0{opacity:0}.opacity-10{opacity:.1}.opacity-100{opacity:1}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.shadow-primary\/20{--tw-shadow-color:rgba(79,70,229,.2);--tw-shadow:var(--tw-shadow-colored)}.shadow-primary\/30{--tw-shadow-color:rgba(79,70,229,.3);--tw-shadow:var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-black{--tw-ring-opacity:1;--tw-ring-color:rgb(0 0 0/var(--tw-ring-opacity,1))}.ring-opacity-5{--tw-ring-opacity:0.05}.blur{--tw-blur:blur(8px)}.blur,.blur-3xl{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur:blur(64px)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.last\:border-0:last-child{border-width:0}.last\:border-b-0:last-child{border-bottom-width:0}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:-translate-y-1:hover,.hover\:scale-105:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.hover\:bg-blue-200:hover{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity,1))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-500:hover{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.hover\:bg-blue-600:hover{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-gray-800\/70:hover{background-color:rgba(31,41,55,.7)}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.hover\:bg-indigo-200:hover{--tw-bg-opacity:1;background-color:rgb(199 210 254/var(--tw-bg-opacity,1))}.hover\:bg-indigo-50:hover{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.hover\:bg-indigo-800:hover{--tw-bg-opacity:1;background-color:rgb(55 48 163/var(--tw-bg-opacity,1))}.hover\:bg-primary\/10:hover{background-color:rgba(79,70,229,.1)}.hover\:bg-primary\/90:hover{background-color:rgba(79,70,229,.9)}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-500:hover{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:text-gray-300:hover{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.hover\:text-gray-500:hover{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-indigo-800:hover{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.hover\:text-primary\/80:hover{color:rgba(79,70,229,.8)}.hover\:text-red-300:hover{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.hover\:text-red-400:hover{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.hover\:text-red-500:hover{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-lg:hover,.hover\:shadow-md:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-gray-700:focus{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-0:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-0:focus,.focus\:ring-1:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-inset:focus{--tw-ring-inset:inset}.focus\:ring-primary:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity,1))}.focus\:ring-primary\/40:focus{--tw-ring-color:rgba(79,70,229,.4)}.focus\:ring-primary\/50:focus{--tw-ring-color:rgba(79,70,229,.5)}.focus\:ring-opacity-50:focus{--tw-ring-opacity:0.5}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-primary\/40:focus-visible{--tw-ring-color:rgba(79,70,229,.4)}.disabled\:opacity-40:disabled{opacity:.4}.group:focus-within .group-focus-within\:visible{visibility:visible}.group:focus-within .group-focus-within\:rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:focus-within .group-focus-within\:opacity-100{opacity:1}.group:hover .group-hover\:visible{visibility:visible}.group:hover .group-hover\:rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:border-primary{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.peer:checked~.peer-checked\:bg-primary{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.peer:checked~.peer-checked\:opacity-100{opacity:1}@media (min-width:640px){.sm\:relative{position:relative}.sm\:left-auto{left:auto}.sm\:right-auto{right:auto}.sm\:top-auto{top:auto}.sm\:ml-10{margin-left:2.5rem}.sm\:ml-auto{margin-left:auto}.sm\:mt-0{margin-top:0}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:h-16{height:4rem}.sm\:h-auto{height:auto}.sm\:w-16{width:4rem}.sm\:w-auto{width:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-end{justify-content:flex-end}.sm\:gap-4{gap:1rem}.sm\:gap-6{gap:1.5rem}.sm\:space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.sm\:p-6{padding:1.5rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-base{font-size:1rem;line-height:1.5rem}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:768px){.md\:sticky{position:sticky}.md\:top-\[calc\(var\(--header-h\)\+16px\)\]{top:calc(var(--header-h) + 16px)}.md\:col-span-2{grid-column:span 2/span 2}.md\:ml-3{margin-left:.75rem}.md\:w-1\/2{width:50%}.md\:w-44{width:11rem}.md\:w-64{width:16rem}.md\:w-80{width:20rem}.md\:flex-1{flex:1 1 0%}.md\:shrink-0{flex-shrink:0}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-end{justify-content:flex-end}.md\:justify-between{justify-content:space-between}.md\:gap-4{gap:1rem}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.md\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.md\:self-start{align-self:flex-start}.md\:p-4{padding:1rem}.md\:p-5{padding:1.25rem}.md\:p-8{padding:2rem}.md\:px-6{padding-left:1.5rem;padding-right:1.5rem}.md\:px-8{padding-left:2rem;padding-right:2rem}.md\:pt-20{padding-top:5rem}.md\:text-3xl{font-size:1.875rem;line-height:2.25rem}.md\:text-base{font-size:1rem;line-height:1.5rem}.md\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file diff --git a/src/main/resources/templates/adminpage/user/school/manageSchool.html b/src/main/resources/templates/adminpage/user/school/manageSchool.html index 45240f6..dfced4c 100644 --- a/src/main/resources/templates/adminpage/user/school/manageSchool.html +++ b/src/main/resources/templates/adminpage/user/school/manageSchool.html @@ -139,12 +139,63 @@ @@ -847,7 +971,36 @@ } } + function resetIntroEditorForm() { + const inputIds = [ + "intro-title", + "intro-description", + "schoolPicUrl", + "basic-schoolName", + "basic-builtAt", + "basic-location", + "basic-feature", + "college-rank", + "homepage-url", + "english-url", + "map-url" + ]; + + inputIds.forEach((id) => { + const el = document.getElementById(id); + if (el) el.value = ""; + }); + + const advantages = document.getElementById("advantages-list"); + const urls = document.getElementById("url-list"); + const colleges = document.getElementById("college-list"); + if (advantages) advantages.innerHTML = ""; + if (urls) urls.innerHTML = ""; + if (colleges) colleges.innerHTML = ""; + } + function showBoardList() { + resetIntroEditorForm(); document.getElementById("board-list-view").classList.remove("hidden"); document.getElementById("intro-board-editor").classList.add("hidden"); document.getElementById("board-list-editor").classList.add("hidden"); @@ -876,6 +1029,7 @@ } function showBoardListEditor() { + resetIntroEditorForm(); resetFilePostEditor(); window.currentPostId = null; document.getElementById("board-list-view").classList.add("hidden"); @@ -919,6 +1073,7 @@ document.getElementById("schedule-list-editor").classList.add("hidden"); document.getElementById("post-editor").classList.add("hidden"); document.getElementById("filePost-editor").classList.add("hidden"); + resetIntroEditorForm(); loadIntroBoard(boardId); window.currentView = "intro-editor"; } @@ -1042,42 +1197,33 @@
-
- - -
- -
- - -

해당 단과대의 주 교육 과정을 선택해 주세요.

-
+ +
+
+ + +
- -
- - +
+ + +

해당 단과대의 주 교육 과정을 선택해 주세요.

+
-
@@ -1109,7 +1254,7 @@
`; - list.appendChild(wrapper); + list.prepend(wrapper); if (id !== null) wrapper.dataset.id = id; @@ -1149,14 +1294,31 @@ if (!input || !chipsContainer) return; - // Enter 키로 태그 추가 + // 입력 이벤트: 붙여넣기 등으로 줄바꿈이 들어왔을 때 처리 + input.addEventListener("input", function () { + if (input.value.includes("\n")) { + const lines = input.value.split("\n"); + lines.forEach(line => { + const trimmed = line.trim(); + if (trimmed) { + addDepartmentChip(chipsContainer, trimmed); + } + }); + input.value = ""; + } + }); + + // Enter 키: 줄바꿈 대신 입력 완료로 처리 input.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); - const value = input.value.trim(); - if (!value) return; - - addDepartmentChip(chipsContainer, value); + const lines = input.value.split("\n"); + lines.forEach(line => { + const trimmed = line.trim(); + if (trimmed) { + addDepartmentChip(chipsContainer, trimmed); + } + }); input.value = ""; } }); @@ -1710,10 +1872,11 @@ const modal = document.getElementById("addCategoryModal"); const schoolId = modal.dataset.schoolId; - const boardTitle = document.querySelector('input[placeholder="카테고리 이름을 입력하세요"]').value; - const boardDescription = document.querySelectorAll('input[placeholder="카테고리 이름을 입력하세요"]')[1].value; + const textInputs = modal.querySelectorAll('input[type="text"]'); + const boardTitle = (textInputs[0]?.value || "").trim(); + const boardDescription = (textInputs[1]?.value || "").trim(); - const boardTypeValue = document.querySelector('input[name="boardType"]:checked').value; + const boardTypeValue = modal.querySelector('input[name="boardType"]:checked')?.value || "notice"; console.log("boardType:", boardTypeValue); @@ -1746,6 +1909,9 @@ }) .then(result => { alert("카테고리가 성공적으로 추가되었습니다."); + if (window.closeAddCategoryModal) { + window.closeAddCategoryModal(); + } window.location.href = "/admin/school"; }) .catch(error => { @@ -1865,4 +2031,4 @@ }); - \ No newline at end of file + diff --git a/src/main/resources/templates/fragments/layout/university-layout.html b/src/main/resources/templates/fragments/layout/university-layout.html index 18ab631..df64ca0 100644 --- a/src/main/resources/templates/fragments/layout/university-layout.html +++ b/src/main/resources/templates/fragments/layout/university-layout.html @@ -128,7 +128,7 @@ -
+
diff --git a/src/main/resources/templates/fragments/school/admin/board-list.html b/src/main/resources/templates/fragments/school/admin/board-list.html index 92c49e2..a6b98cf 100644 --- a/src/main/resources/templates/fragments/school/admin/board-list.html +++ b/src/main/resources/templates/fragments/school/admin/board-list.html @@ -55,6 +55,16 @@

게시판 관리

+ +
+
@@ -135,6 +140,11 @@

기본 정보

학교의 이름, 설립연도, 위치, 간단한 특징을 입력해주세요.

+
@@ -201,6 +211,11 @@

대학 랭킹

내부 정렬이나 노출 순서를 위한 숫자입니다. 숫자가 작을수록 상단에 노출됩니다.

+
@@ -227,6 +242,11 @@

추가 링크 목록

입학 안내, 장학 제도, 기숙사 안내 등 자주 사용하는 페이지 링크를 등록할 수 있습니다.

+
@@ -248,12 +268,19 @@

단과대 정보

여러 단과대를 추가하고, 각 단과대에 포함된 학과들을 입력할 수 있습니다.

- +
+ + +
@@ -269,6 +296,11 @@

홈페이지 URL

대표 국문/영문 홈페이지 주소를 입력합니다.

+
@@ -323,4 +355,4 @@

홈페이지 URL

- \ No newline at end of file + diff --git a/src/main/resources/templates/fragments/school/admin/modals.html b/src/main/resources/templates/fragments/school/admin/modals.html index d54e63a..e6f3b32 100644 --- a/src/main/resources/templates/fragments/school/admin/modals.html +++ b/src/main/resources/templates/fragments/school/admin/modals.html @@ -64,6 +64,56 @@

새 게시판 추가

+ + + +