diff --git a/src/main/kotlin/apply/application/EvaluationDtos.kt b/src/main/kotlin/apply/application/EvaluationDtos.kt index 24633d4c8..f1a544be1 100644 --- a/src/main/kotlin/apply/application/EvaluationDtos.kt +++ b/src/main/kotlin/apply/application/EvaluationDtos.kt @@ -150,8 +150,11 @@ data class EvaluationTargetData( ) data class MailTargetResponse( + val name: String, val email: String -) +) { + constructor(applicantResponse: ApplicantResponse) : this(applicantResponse.name, applicantResponse.email) +} data class EvaluationItemScoreData( @field:NotNull diff --git a/src/main/kotlin/apply/application/MailTargetService.kt b/src/main/kotlin/apply/application/MailTargetService.kt index fd097f18f..51d92590a 100644 --- a/src/main/kotlin/apply/application/MailTargetService.kt +++ b/src/main/kotlin/apply/application/MailTargetService.kt @@ -16,7 +16,7 @@ class MailTargetService( fun findMailTargets(evaluationId: Long, evaluationStatus: EvaluationStatus? = null): List { val applicantIds = findEvaluationTargets(evaluationId, evaluationStatus).map { it.applicantId } return applicantRepository.findAllById(applicantIds) - .map { MailTargetResponse(it.email) } + .map { MailTargetResponse(it.name, it.email) } } private fun findEvaluationTargets(evaluationId: Long, evaluationStatus: EvaluationStatus?): List { diff --git a/src/main/kotlin/apply/application/mail/MailData.kt b/src/main/kotlin/apply/application/mail/MailData.kt new file mode 100644 index 000000000..36ca705a2 --- /dev/null +++ b/src/main/kotlin/apply/application/mail/MailData.kt @@ -0,0 +1,14 @@ +package apply.application.mail + +import javax.validation.constraints.NotEmpty + +data class MailData( + @field:NotEmpty + var subject: String = "", + + @field:NotEmpty + var body: String = "", + + @field:NotEmpty + var recipients: List = emptyList() +) diff --git a/src/main/kotlin/apply/ui/admin/BaseLayout.kt b/src/main/kotlin/apply/ui/admin/BaseLayout.kt index 3173e9ae5..e112c20d5 100644 --- a/src/main/kotlin/apply/ui/admin/BaseLayout.kt +++ b/src/main/kotlin/apply/ui/admin/BaseLayout.kt @@ -2,6 +2,7 @@ package apply.ui.admin import apply.ui.admin.cheater.CheatersView import apply.ui.admin.evaluation.EvaluationsView +import apply.ui.admin.mail.MailFormView import apply.ui.admin.mission.MissionSelectionsView import apply.ui.admin.recruitment.RecruitmentsView import apply.ui.admin.selections.SelectionsView @@ -27,7 +28,8 @@ class BaseLayout : AppLayout() { "평가 관리" to EvaluationsView::class.java, "과제 관리" to MissionSelectionsView::class.java, "선발 과정" to SelectionsView::class.java, - "부정 행위자" to CheatersView::class.java + "부정 행위자" to CheatersView::class.java, + "메일 발송" to MailFormView::class.java ) init { diff --git a/src/main/kotlin/apply/ui/admin/cheater/CheaterForm.kt b/src/main/kotlin/apply/ui/admin/cheater/CheaterForm.kt index d11340bfb..324f3c533 100644 --- a/src/main/kotlin/apply/ui/admin/cheater/CheaterForm.kt +++ b/src/main/kotlin/apply/ui/admin/cheater/CheaterForm.kt @@ -9,7 +9,7 @@ import com.vaadin.flow.component.select.Select import com.vaadin.flow.component.textfield.TextArea import support.views.BindingFormLayout import support.views.createItemSelect -import support.views.createSearchBar +import support.views.createSearchBox class CheaterForm() : BindingFormLayout(CheaterData::class) { private val applicants: Select = createItemSelect().apply { @@ -26,7 +26,7 @@ class CheaterForm() : BindingFormLayout(CheaterData::class) { } private fun createApplicantSearchBar(listener: (String) -> List): Component { - val searchBar = createSearchBar("회원 검색") { + val searchBar = createSearchBox("회원 검색") { applicants.setItems(listener(it)) } return HorizontalLayout(searchBar, applicants).apply { diff --git a/src/main/kotlin/apply/ui/admin/cheater/CheatersView.kt b/src/main/kotlin/apply/ui/admin/cheater/CheatersView.kt index 00e0ce5ea..bd6623ee0 100644 --- a/src/main/kotlin/apply/ui/admin/cheater/CheatersView.kt +++ b/src/main/kotlin/apply/ui/admin/cheater/CheatersView.kt @@ -14,6 +14,7 @@ import com.vaadin.flow.component.orderedlayout.VerticalLayout import com.vaadin.flow.data.renderer.ComponentRenderer import com.vaadin.flow.data.renderer.Renderer import com.vaadin.flow.router.Route +import support.views.NO_NAME import support.views.addSortableColumn import support.views.addSortableDateTimeColumn import support.views.createDeleteButtonWithDialog @@ -50,7 +51,7 @@ class CheatersView( private fun createCheaterGrid(): Grid { return Grid(10).apply { - addSortableColumn("이름") { it.name ?: "(이름 없음)" } + addSortableColumn("이름") { it.name ?: NO_NAME } addSortableColumn("이메일") { it.email } addSortableDateTimeColumn("등록일", CheaterResponse::createdDateTime) addSortableColumn("설명") { it.description } diff --git a/src/main/kotlin/apply/ui/admin/mail/GroupMailTargetDialog.kt b/src/main/kotlin/apply/ui/admin/mail/GroupMailTargetDialog.kt new file mode 100644 index 000000000..c859f090f --- /dev/null +++ b/src/main/kotlin/apply/ui/admin/mail/GroupMailTargetDialog.kt @@ -0,0 +1,102 @@ +package apply.ui.admin.mail + +import apply.application.EvaluationService +import apply.application.MailTargetResponse +import apply.application.MailTargetService +import apply.application.RecruitmentResponse +import apply.application.RecruitmentService +import apply.domain.evaluation.Evaluation +import apply.domain.evaluationtarget.EvaluationStatus +import com.vaadin.flow.component.Component +import com.vaadin.flow.component.button.Button +import com.vaadin.flow.component.dialog.Dialog +import com.vaadin.flow.component.grid.Grid +import com.vaadin.flow.component.html.H2 +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.component.select.Select +import com.vaadin.flow.data.provider.ListDataProvider +import support.views.addSortableColumn +import support.views.createContrastButton +import support.views.createItemSelect +import support.views.createPrimaryButton +import support.views.toText + +class GroupMailTargetDialog( + private val recruitmentService: RecruitmentService, + private val evaluationService: EvaluationService, + private val mailTargetService: MailTargetService, + private val accept: (Collection) -> Unit +) : Dialog() { + private val mailTargetsGrid: Grid = createMailTargetsGrid() + + init { + add(H2("그룹 불러오기"), createSearchFilter(), mailTargetsGrid, createButtons()) + width = "900px" + height = "70%" + open() + } + + private fun createMailTargetsGrid(): Grid { + return Grid(10).apply { + addSortableColumn("이름", MailTargetResponse::name) + addSortableColumn("이메일", MailTargetResponse::email) + } + } + + private fun createSearchFilter(): HorizontalLayout { + val evaluationItem = createItemSelect("평가") + return HorizontalLayout( + createRecruitmentItem(evaluationItem), evaluationItem, createEvaluationStatusItem(evaluationItem), + ).apply { + element.style.set("margin-bottom", "10px") + } + } + + private fun createRecruitmentItem(evaluationItem: Select): Select { + return createItemSelect("모집").apply { + setItems(*recruitmentService.findAll().toTypedArray()) + setItemLabelGenerator { it.title } + addValueChangeListener { + evaluationItem.apply { + setItems(*evaluationService.findAllByRecruitmentId(it.value.id).toTypedArray()) + setItemLabelGenerator { it.title } + } + } + } + } + + private fun createEvaluationStatusItem( + evaluationItem: Select + ): Select { + return createItemSelect("평가 상태").apply { + setItems(*EvaluationStatus.values()) + setItemLabelGenerator { it.toText() } + addValueChangeListener { + mailTargetsGrid.setItems(mailTargetService.findMailTargets(evaluationItem.value.id, it.value)) + } + } + } + + private fun createButtons(): Component { + return HorizontalLayout(createAddButton(), createCancelButton()).apply { + setSizeFull() + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + element.style.set("margin-top", "10px") + } + } + + private fun createAddButton(): Button { + return createPrimaryButton("추가") { + val dataProvider = mailTargetsGrid.dataProvider as ListDataProvider + accept(dataProvider.items) + close() + } + } + + private fun createCancelButton(): Button { + return createContrastButton("취소") { + close() + } + } +} diff --git a/src/main/kotlin/apply/ui/admin/mail/IndividualMailTargetDialog.kt b/src/main/kotlin/apply/ui/admin/mail/IndividualMailTargetDialog.kt new file mode 100644 index 000000000..58a6578a7 --- /dev/null +++ b/src/main/kotlin/apply/ui/admin/mail/IndividualMailTargetDialog.kt @@ -0,0 +1,73 @@ +package apply.ui.admin.mail + +import apply.application.ApplicantResponse +import apply.application.ApplicantService +import apply.application.MailTargetResponse +import com.vaadin.flow.component.Component +import com.vaadin.flow.component.button.Button +import com.vaadin.flow.component.dialog.Dialog +import com.vaadin.flow.component.grid.Grid +import com.vaadin.flow.component.html.H2 +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.data.renderer.ComponentRenderer +import com.vaadin.flow.data.renderer.Renderer +import support.views.addSortableColumn +import support.views.createContrastButton +import support.views.createPrimarySmallButton +import support.views.createSearchBox + +class IndividualMailTargetDialog( + private val applicantService: ApplicantService, + private val accept: (MailTargetResponse) -> Unit +) : Dialog() { + private val mailTargetsGrid: Grid = createMailTargetsGrid() + + init { + add(H2("개별 불러오기"), createSearchFilter(), mailTargetsGrid, createButtons()) + width = "900px" + height = "70%" + open() + } + + private fun createSearchFilter(): Component { + return HorizontalLayout( + createSearchBox { mailTargetsGrid.setItems(applicantService.findAllByKeyword(it)) } + ).apply { + element.style.set("margin-top", "10px") + element.style.set("margin-bottom", "10px") + } + } + + private fun createMailTargetsGrid(): Grid { + return Grid(10).apply { + addSortableColumn("이름", ApplicantResponse::name) + addSortableColumn("이메일", ApplicantResponse::email) + addColumn(createAddButton()).apply { isAutoWidth = true } + } + } + + private fun createAddButton(): Renderer { + return ComponentRenderer { applicantResponse -> + createPrimarySmallButton("추가") { + accept(MailTargetResponse(applicantResponse)) + }.apply { + isDisableOnClick = true + } + } + } + + private fun createButtons(): Component { + return HorizontalLayout(createCancelButton()).apply { + setSizeFull() + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + element.style.set("margin-top", "10px") + } + } + + private fun createCancelButton(): Button { + return createContrastButton("취소") { + close() + } + } +} diff --git a/src/main/kotlin/apply/ui/admin/mail/MailFormView.kt b/src/main/kotlin/apply/ui/admin/mail/MailFormView.kt new file mode 100644 index 000000000..316984f3d --- /dev/null +++ b/src/main/kotlin/apply/ui/admin/mail/MailFormView.kt @@ -0,0 +1,178 @@ +package apply.ui.admin.mail + +import apply.application.ApplicantService +import apply.application.EvaluationService +import apply.application.MailTargetResponse +import apply.application.MailTargetService +import apply.application.RecruitmentService +import apply.application.mail.MailData +import apply.ui.admin.BaseLayout +import com.vaadin.flow.component.Component +import com.vaadin.flow.component.UI +import com.vaadin.flow.component.button.Button +import com.vaadin.flow.component.grid.Grid +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.component.orderedlayout.VerticalLayout +import com.vaadin.flow.component.textfield.TextArea +import com.vaadin.flow.component.textfield.TextField +import com.vaadin.flow.component.upload.receivers.MultiFileMemoryBuffer +import com.vaadin.flow.data.renderer.ComponentRenderer +import com.vaadin.flow.data.renderer.Renderer +import com.vaadin.flow.router.Route +import org.springframework.boot.autoconfigure.mail.MailProperties +import support.views.BindingFormLayout +import support.views.NO_NAME +import support.views.Title +import support.views.addSortableColumn +import support.views.createContrastButton +import support.views.createEnterBox +import support.views.createErrorSmallButton +import support.views.createNormalButton +import support.views.createPrimaryButton +import support.views.createUpload + +@Route(value = "admin/mails", layout = BaseLayout::class) +class MailFormView( + private val applicantService: ApplicantService, + private val recruitmentService: RecruitmentService, + private val evaluationService: EvaluationService, + private val mailTargetService: MailTargetService, + private val mailProperties: MailProperties +) : BindingFormLayout(MailData::class) { + private val subject: TextField = TextField("제목").apply { setWidthFull() } + private val body: TextArea = createBody() + private val mailTargets: MutableSet = mutableSetOf() + private val mailTargetsGrid: Grid = createMailTargetsGrid(mailTargets) + + init { + add(Title("메일 발송"), createMailForm()) + setResponsiveSteps(ResponsiveStep("0", 1)) + drawRequired() + } + + private fun createMailForm(): Component { + return VerticalLayout( + subject, + createSender(), + createRecipientFilter(), + mailTargetsGrid, + body, + createUpload("파일첨부", MultiFileMemoryBuffer()) { + // TODO: 추후 업로드 된 파일을 메일로 첨부하는 로직이 추가되어야 함 + // (uploadFiles 같은 필드를 두고 mail을 보내는 기능에 포함시키면 될 것 같음) + // it.files.forEach { fileName -> + // val fileData = it.getFileData(fileName) + // val inputStream = it.getInputStream(fileName) + // val readBytes = inputStream.readBytes() + // } + }, + createButtons() + ).apply { + setSizeFull() + } + } + + private fun createSender(): Component { + return TextField("보낸사람").apply { + value = mailProperties.username + isReadOnly = true + } + } + + private fun createRecipientFilter(): Component { + return HorizontalLayout( + createEnterBox(labelText = "받는사람") { + if (it.isNotBlank()) { + mailTargets.addAndRefresh(MailTargetResponse(NO_NAME, it)) + } + }, + createIndividualLoadButton(), + createGroupLoadButton() + ).apply { defaultVerticalComponentAlignment = FlexComponent.Alignment.END } + } + + private fun createMailTargetsGrid(mailTargets: Set): Grid { + return Grid(10).apply { + addSortableColumn("이름", MailTargetResponse::name) + addSortableColumn("이메일", MailTargetResponse::email) + addColumn(createRemoveButton()) + setItems(mailTargets) + } + } + + private fun createRemoveButton(): Renderer { + return ComponentRenderer { response -> + createErrorSmallButton("제거") { + mailTargets.removeAndRefresh(response) + } + } + } + + private fun createIndividualLoadButton(): Button { + return createNormalButton("개별 불러오기") { + IndividualMailTargetDialog(applicantService) { + mailTargets.addAndRefresh(it) + } + } + } + + private fun createGroupLoadButton(): Component { + return createNormalButton("그룹 불러오기") { + GroupMailTargetDialog(recruitmentService, evaluationService, mailTargetService) { + mailTargets.addAllAndRefresh(it) + } + } + } + + private fun createBody(): TextArea { + return TextArea("본문").apply { + setSizeFull() + style.set("minHeight", "400px") + } + } + + private fun createButtons(): Component { + return HorizontalLayout(createSubmitButton(), createCancelButton()).apply { + setSizeFull() + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + } + } + + private fun createSubmitButton(): Button { + return createPrimaryButton("보내기") { + bindOrNull()?.let { + // TODO: emailService.메일전송(it, uploadFile) + UI.getCurrent().navigate(MailFormView::class.java) + } + } + } + + private fun createCancelButton(): Button { + return createContrastButton("취소") { + UI.getCurrent().navigate(MailFormView::class.java) + } + } + + private fun MutableSet.addAndRefresh(element: MailTargetResponse) { + add(element).also { mailTargetsGrid.dataProvider.refreshAll() } + } + + private fun MutableSet.addAllAndRefresh(elements: Collection) { + addAll(elements).also { mailTargetsGrid.dataProvider.refreshAll() } + } + + private fun MutableSet.removeAndRefresh(element: MailTargetResponse) { + remove(element).also { mailTargetsGrid.dataProvider.refreshAll() } + } + + override fun bindOrNull(): MailData? { + return bindDefaultOrNull()?.apply { + recipients = mailTargets.map { it.email }.toList() + } + } + + override fun fill(data: MailData) { + fillDefault(data) + } +} diff --git a/src/main/kotlin/apply/ui/admin/selections/EvaluationTargetForm.kt b/src/main/kotlin/apply/ui/admin/selections/EvaluationTargetForm.kt index 494d7f09e..d9fa8b387 100644 --- a/src/main/kotlin/apply/ui/admin/selections/EvaluationTargetForm.kt +++ b/src/main/kotlin/apply/ui/admin/selections/EvaluationTargetForm.kt @@ -8,6 +8,7 @@ import com.vaadin.flow.component.textfield.IntegerField import com.vaadin.flow.component.textfield.TextArea import support.views.BindingFormLayout import support.views.createItemSelect +import support.views.toText class EvaluationTargetForm() : BindingFormLayout(EvaluationTargetData::class) { private val evaluationItemScores: MutableList = mutableListOf() @@ -36,14 +37,6 @@ class EvaluationTargetForm() : BindingFormLayout(Evaluatio private fun sumOfScore() = evaluationItemScores.map { it.score.value }.sum() - private fun EvaluationStatus.toText() = - when (this) { - EvaluationStatus.WAITING -> "평가 전" - EvaluationStatus.PASS -> "합격" - EvaluationStatus.FAIL -> "탈락" - EvaluationStatus.PENDING -> "보류" - } - private fun getIndexOfLastAnswer() = (children.count() - FIXED_ADDED_COMPONENT_COUNT).toInt() override fun bindOrNull(): EvaluationTargetData? { diff --git a/src/main/kotlin/apply/ui/admin/selections/SelectionView.kt b/src/main/kotlin/apply/ui/admin/selections/SelectionView.kt index 8b3d4d119..138c2fa07 100644 --- a/src/main/kotlin/apply/ui/admin/selections/SelectionView.kt +++ b/src/main/kotlin/apply/ui/admin/selections/SelectionView.kt @@ -42,7 +42,7 @@ import support.views.createCsvUpload import support.views.createNormalButton import support.views.createPrimaryButton import support.views.createPrimarySmallButton -import support.views.createSearchBar +import support.views.createSearchBox import support.views.createSuccessButton import support.views.downloadFile @@ -78,7 +78,7 @@ class SelectionView( val tabsToGrids: Map = mapTabAndGrid(keyword) val (tabs, grids) = createTabComponents(tabsToGrids) val menu = HorizontalLayout( - createSearchBar { + createSearchBox { removeAll() add( createTitle(), diff --git a/src/main/kotlin/support/views/Buttons.kt b/src/main/kotlin/support/views/Buttons.kt index 668ec0220..5364ca741 100644 --- a/src/main/kotlin/support/views/Buttons.kt +++ b/src/main/kotlin/support/views/Buttons.kt @@ -10,9 +10,11 @@ import com.vaadin.flow.component.orderedlayout.FlexComponent import com.vaadin.flow.component.orderedlayout.HorizontalLayout import com.vaadin.flow.component.upload.Upload import com.vaadin.flow.component.upload.receivers.MemoryBuffer +import com.vaadin.flow.component.upload.receivers.MultiFileMemoryBuffer typealias ClickListener = (ClickEvent