diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/budget/entity/Budget.java b/Backend/src/main/java/com/luckyseven/backend/domain/budget/entity/Budget.java index ce643efa..14d07fdd 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/budget/entity/Budget.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/budget/entity/Budget.java @@ -1,5 +1,6 @@ package com.luckyseven.backend.domain.budget.entity; +import com.luckyseven.backend.domain.budget.dto.BudgetUpdateRequest; import com.luckyseven.backend.domain.team.entity.Team; import com.luckyseven.backend.sharedkernel.entity.BaseEntity; import jakarta.persistence.Column; @@ -38,17 +39,16 @@ public class Budget extends BaseEntity { @Column(nullable = false) private Long setBy; - @Setter @Column(nullable = false) private BigDecimal balance; - @Setter + private BigDecimal foreignBalance; + @Setter @Enumerated(EnumType.STRING) @Column(nullable = false, length = 3) private CurrencyCode foreignCurrency; - @Setter private BigDecimal avgExchangeRate; @Builder @@ -66,7 +66,13 @@ public Budget(Team team, BigDecimal totalAmount, Long setBy, public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; - this.balance = totalAmount; + } + + public void addBalance(BudgetUpdateRequest request) { + if (request.additionalBudget() == null) { + return; + } + this.balance = this.balance.add(request.additionalBudget()); } public void setExchangeInfo(boolean isExchanged, BigDecimal amount, BigDecimal exchangeRate) { @@ -77,8 +83,6 @@ public void setExchangeInfo(boolean isExchanged, BigDecimal amount, BigDecimal e } updateForeignBalance(amount, exchangeRate); - this.avgExchangeRate = exchangeRate; - } public void updateExchangeInfo(boolean isExchanged, BigDecimal amount, BigDecimal exchangeRate) { @@ -86,20 +90,23 @@ public void updateExchangeInfo(boolean isExchanged, BigDecimal amount, BigDecima return; } - updateForeignBalance(amount, exchangeRate); updateAvgExchangeRate(amount, exchangeRate); + updateForeignBalance(amount, exchangeRate); } // 예산 추가 후 외화잔고 및 평균환율 수정 private void updateAvgExchangeRate(BigDecimal amount, BigDecimal exchangeRate) { - if (this.avgExchangeRate == null) { + if (this.avgExchangeRate == null || this.avgExchangeRate.compareTo(BigDecimal.ZERO) == 0) { avgExchangeRate = exchangeRate; return; } - this.avgExchangeRate = (this.balance.multiply(this.avgExchangeRate) - .add(amount.multiply(exchangeRate))) - .divide(this.balance.add(amount), 2, RoundingMode.HALF_UP); + BigDecimal foreignAmount = amount.divide(exchangeRate, 10, + RoundingMode.HALF_UP); // 외화 환산, 충분한 정밀도 확보 + BigDecimal totalCost = this.foreignBalance.multiply(this.avgExchangeRate).add(amount); + BigDecimal totalForeign = this.foreignBalance.add(foreignAmount); + this.avgExchangeRate = totalCost.divide(totalForeign, 2, RoundingMode.HALF_UP); + } private void updateForeignBalance(BigDecimal amount, BigDecimal exchangeRate) { @@ -116,6 +123,10 @@ public void setForeignBalance() { } } + public void setForeignBalance(BigDecimal amount) { + this.foreignBalance = amount; + } + public Budget setTeam(Team team) { // 기존 연결 해제 if (this.team != null) { @@ -131,6 +142,7 @@ public Budget setTeam(Team team) { return this; } + public void updateBalance(BigDecimal balance) { if (balance != null) { this.balance = balance; diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/budget/service/BudgetService.java b/Backend/src/main/java/com/luckyseven/backend/domain/budget/service/BudgetService.java index 4713ee6c..0dac2243 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/budget/service/BudgetService.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/budget/service/BudgetService.java @@ -44,6 +44,7 @@ public BudgetCreateResponse save(Long teamId, Long loginMemberId, BudgetCreateRe request.exchangeRate()); budgetRepository.save(budget); + team.setBudget(budget); return budgetMapper.toCreateResponse(budget); } @@ -74,7 +75,14 @@ public BudgetUpdateResponse updateByTeamId(Long teamId, Long loginMemberId, @Transactional public void deleteByTeamId(Long teamId) { + Team team = teamRepository.findById(teamId) + .orElseThrow(() -> new EntityNotFoundException("팀을 찾을 수 없습니다: " + teamId)); + + Budget budget = budgetValidator.validateBudgetExist(teamId); + + team.setBudget(null); + teamRepository.save(team); budgetRepository.delete(budget); } @@ -85,6 +93,7 @@ private static void addBudget(BudgetUpdateRequest request, Budget budget) { request.additionalBudget(), request.exchangeRate()); budget.setTotalAmount(budget.getTotalAmount().add(request.additionalBudget())); + budget.addBalance(request); } } diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/expense/service/ExpenseService.java b/Backend/src/main/java/com/luckyseven/backend/domain/expense/service/ExpenseService.java index 7c9543bb..b03795b4 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/expense/service/ExpenseService.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/expense/service/ExpenseService.java @@ -11,6 +11,7 @@ import com.luckyseven.backend.domain.expense.dto.ExpenseResponse; import com.luckyseven.backend.domain.expense.dto.ExpenseUpdateRequest; import com.luckyseven.backend.domain.expense.entity.Expense; +import com.luckyseven.backend.domain.expense.enums.PaymentMethod; import com.luckyseven.backend.domain.expense.mapper.ExpenseMapper; import com.luckyseven.backend.domain.expense.repository.ExpenseRepository; import com.luckyseven.backend.domain.member.entity.Member; @@ -46,13 +47,24 @@ public CreateExpenseResponse saveExpense(Long teamId, ExpenseRequest request) { Member payer = findPayerOrThrow(request.payerId()); Budget budget = team.getBudget(); - validateSufficientBudget(request.amount(), budget.getBalance()); + + if (request.paymentMethod() == PaymentMethod.CASH) { + BigDecimal foreignAmount = request.amount(); + BigDecimal KRWAmount = foreignAmount.multiply(budget.getAvgExchangeRate()); + validateSufficientBudget(KRWAmount, budget.getBalance()); + validateSufficientBudget(foreignAmount, budget.getForeignBalance()); + budget.updateBalance(budget.getBalance().subtract(KRWAmount)); + budget.setForeignBalance(budget.getForeignBalance().subtract(foreignAmount)); + + } else if (request.paymentMethod() == PaymentMethod.CARD) { + validateSufficientBudget(request.amount(), budget.getBalance()); + budget.updateBalance(budget.getBalance().subtract(request.amount())); + } Expense expense = ExpenseMapper.fromExpenseRequest(request, team, payer); Expense saved = expenseRepository.save(expense); // TODO: 낙관적 락(Lock) 적용 검토 - budget.updateBalance(budget.getBalance().subtract(request.amount())); createAllSettlements(request, payer, saved); diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/settlements/app/SettlementService.java b/Backend/src/main/java/com/luckyseven/backend/domain/settlements/app/SettlementService.java index 419fe52c..1ef7f8e2 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/settlements/app/SettlementService.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/settlements/app/SettlementService.java @@ -137,7 +137,7 @@ private Expense getExpense(SettlementUpdateRequest request) { @Transactional public SettlementResponse settleSettlement(Long id) { Settlement settlement = findSettlementOrThrow(id); - settlement.setSettled(); + settlement.convertSettled(); return SettlementMapper.toSettlementResponse(settlementRepository.save(settlement)); } diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/settlements/dto/SettlementResponse.java b/Backend/src/main/java/com/luckyseven/backend/domain/settlements/dto/SettlementResponse.java index 1f3e6f63..6cd4bd85 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/settlements/dto/SettlementResponse.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/settlements/dto/SettlementResponse.java @@ -16,8 +16,12 @@ public record SettlementResponse( BigDecimal amount, Boolean isSettled, Long settlerId, + String settlerNickname, Long payerId, - Long expenseId + String payerNickname, + Long expenseId, + String expenseDescription, + Long teamId ) { } \ No newline at end of file diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/settlements/entity/Settlement.java b/Backend/src/main/java/com/luckyseven/backend/domain/settlements/entity/Settlement.java index 6beee9bd..55ef6e83 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/settlements/entity/Settlement.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/settlements/entity/Settlement.java @@ -81,7 +81,7 @@ public void update(BigDecimal amount, Member settler, Member payer, Expense expe } } - public void setSettled() { - this.isSettled = true; + public void convertSettled() { + this.isSettled = !this.isSettled; } } diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/settlements/util/SettlementMapper.java b/Backend/src/main/java/com/luckyseven/backend/domain/settlements/util/SettlementMapper.java index 82d56858..f04a3aef 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/settlements/util/SettlementMapper.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/settlements/util/SettlementMapper.java @@ -20,8 +20,12 @@ public static SettlementResponse toSettlementResponse(Settlement settlement) { .updatedAt(settlement.getUpdatedAt()) .isSettled(settlement.getIsSettled()) .settlerId(settlement.getSettler().getId()) + .settlerNickname(settlement.getSettler().getNickname()) .payerId(settlement.getPayer().getId()) + .payerNickname(settlement.getPayer().getNickname()) .expenseId(settlement.getExpense().getId()) + .expenseDescription(settlement.getExpense().getDescription()) + .teamId(settlement.getExpense().getTeam().getId()) .build(); } diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/team/service/TeamService.java b/Backend/src/main/java/com/luckyseven/backend/domain/team/service/TeamService.java index 1e199750..21a7651e 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/team/service/TeamService.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/team/service/TeamService.java @@ -19,7 +19,6 @@ import com.luckyseven.backend.domain.team.util.TeamMapper; import com.luckyseven.backend.sharedkernel.exception.CustomLogicException; import com.luckyseven.backend.sharedkernel.exception.ExceptionCode; -import java.math.BigDecimal; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @@ -67,20 +66,20 @@ public TeamCreateResponse createTeam(MemberDetails memberDetails // 리더를 TeamMember 에 추가 teamMemberRepository.save(teamMember); - // 예산 생성(임시로 구현) - Budget budget = Budget.builder() - .foreignCurrency(com.luckyseven.backend.domain.budget.entity.CurrencyCode.KRW) // Set default currency to KRW - .balance(BigDecimal.ZERO) - .foreignBalance(BigDecimal.ZERO) - .totalAmount(BigDecimal.ZERO) - .avgExchangeRate(BigDecimal.ONE) - .setBy(memberId) // Set the creator as the setter - .build(); - - // Team이 Budget의 주인이므로, Team 에서 Budget set - Budget savedBudget = budgetRepository.save(budget); - savedTeam.setBudget(savedBudget); - savedBudget.setTeam(savedTeam); +// // 예산 생성(임시로 구현) +// Budget budget = Budget.builder() +// .foreignCurrency(com.luckyseven.backend.domain.budget.entity.CurrencyCode.KRW) // Set default currency to KRW +// .balance(BigDecimal.ZERO) +// .foreignBalance(BigDecimal.ZERO) +// .totalAmount(BigDecimal.ZERO) +// .avgExchangeRate(BigDecimal.ONE) +// .setBy(memberId) // Set the creator as the setter +// .build(); +// +// // Team이 Budget의 주인이므로, Team 에서 Budget set +// Budget savedBudget = budgetRepository.save(budget); +// savedTeam.setBudget(savedBudget); +// savedBudget.setTeam(savedTeam); savedTeam.addTeamMember(teamMember); return TeamMapper.toTeamCreateResponse(savedTeam); @@ -164,9 +163,12 @@ public TeamDashboardResponse getTeamDashboard(Long teamId) { .orElseThrow(() -> new CustomLogicException(ExceptionCode.TEAM_NOT_FOUND, "ID가 [%d]인 팀을 찾을 수 없습니다", teamId)); - Budget budget = budgetRepository.findByTeamId(teamId) - .orElseThrow(() -> new CustomLogicException(ExceptionCode.BUDGET_NOT_FOUND, - "팀 ID [%d]의 예산 정보가 없습니다", teamId)); +// Budget budget = budgetRepository.findByTeamId(teamId) +// .orElseThrow(() -> new CustomLogicException(ExceptionCode.BUDGET_NOT_FOUND, +// "팀 ID [%d]의 예산 정보가 없습니다", teamId)); + + // 예산이 없는 경우 null로 처리 (Optional 사용) + Budget budget = budgetRepository.findByTeamId(teamId).orElse(null); Pageable pageable = PageRequest.of(0, 5, Sort.by("createdAt").descending()); Page expensePage = expenseRepository.findByTeamId(teamId, pageable); diff --git a/Backend/src/test/java/com/luckyseven/backend/domain/budget/controller/BudgetControllerTests.java b/Backend/src/test/java/com/luckyseven/backend/domain/budget/controller/BudgetControllerTests.java index d0212955..5b77efbb 100644 --- a/Backend/src/test/java/com/luckyseven/backend/domain/budget/controller/BudgetControllerTests.java +++ b/Backend/src/test/java/com/luckyseven/backend/domain/budget/controller/BudgetControllerTests.java @@ -20,6 +20,7 @@ import com.luckyseven.backend.domain.budget.service.BudgetService; import com.luckyseven.backend.domain.budget.validator.BudgetValidator; import java.math.BigDecimal; +import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -51,19 +52,11 @@ void create_should_return_201() throws Exception { // given Long teamId = 1L; - BudgetCreateRequest request = BudgetCreateRequest.builder() - .totalAmount(BigDecimal.valueOf(100000)) - .foreignCurrency(CurrencyCode.USD) - .isExchanged(true) - .exchangeRate(BigDecimal.valueOf(1393.7)) - .build(); - BudgetCreateResponse response = BudgetCreateResponse.builder() - .id(1L) - .balance(BigDecimal.valueOf(100000)) - .foreignBalance(BigDecimal.valueOf(71.75)) - .avgExchangeRate(BigDecimal.valueOf(1393.7)) - .build(); - + BudgetCreateRequest request = new BudgetCreateRequest(BigDecimal.valueOf(100000), + true, BigDecimal.valueOf(1393.7), CurrencyCode.USD); + BudgetCreateResponse response = new BudgetCreateResponse(1L, LocalDateTime.now(), + 1L, BigDecimal.valueOf(100000), BigDecimal.valueOf(1393.7), + BigDecimal.valueOf(71.75)); when(budgetService.save(teamId, 2L, request)).thenReturn(response); // when @@ -86,15 +79,10 @@ void read_should_return_200() throws Exception { // given Long teamId = 1L; - BudgetReadResponse response = BudgetReadResponse.builder() - .id(1L) - .totalAmount(BigDecimal.valueOf(100000)) - .setBy(1L) - .balance(BigDecimal.valueOf(100000)) - .foreignCurrency(CurrencyCode.USD) - .foreignBalance(BigDecimal.valueOf(71.75)) - .avgExchangeRate(BigDecimal.valueOf(1393.70)) - .build(); + BudgetReadResponse response = new BudgetReadResponse(1L, LocalDateTime.now(), 1L, + BigDecimal.valueOf(100000), BigDecimal.valueOf(100000), CurrencyCode.USD, + BigDecimal.valueOf(1393.70), BigDecimal.valueOf(71.75)); + when(budgetService.getByTeamId(teamId)).thenReturn(response); @@ -113,16 +101,12 @@ void patch_should_return_200() throws Exception { // given Long teamId = 1L; - BudgetUpdateRequest request = BudgetUpdateRequest.builder() - .totalAmount(BigDecimal.valueOf(150000)) - .build(); - BudgetUpdateResponse response = BudgetUpdateResponse.builder() - .id(1L) - .balance(BigDecimal.valueOf(150000)) - .foreignBalance(BigDecimal.valueOf(107.63)) - .foreignCurrency(CurrencyCode.USD) - .avgExchangeRate(BigDecimal.valueOf(1393.7)) - .build(); + BudgetUpdateRequest request = new BudgetUpdateRequest(BigDecimal.valueOf(150000), + false, null, null); + + BudgetUpdateResponse response = new BudgetUpdateResponse(1L, LocalDateTime.now(), + 1L, BigDecimal.valueOf(150000), CurrencyCode.USD, BigDecimal.valueOf(1393.7), + BigDecimal.valueOf(107.63)); when(budgetService.updateByTeamId(teamId, 2L, request)).thenReturn(response); diff --git a/Backend/src/test/java/com/luckyseven/backend/domain/budget/service/BudgetServiceTests.java b/Backend/src/test/java/com/luckyseven/backend/domain/budget/service/BudgetServiceTests.java index 54e70918..80048ae3 100644 --- a/Backend/src/test/java/com/luckyseven/backend/domain/budget/service/BudgetServiceTests.java +++ b/Backend/src/test/java/com/luckyseven/backend/domain/budget/service/BudgetServiceTests.java @@ -18,6 +18,8 @@ import com.luckyseven.backend.domain.budget.validator.BudgetValidator; import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -44,28 +46,21 @@ void save_create_exchanged_budget_and_return_create_response() throws Exception // given Long teamId = 1L; Long loginMemberId = 1L; - BudgetCreateRequest request = BudgetCreateRequest.builder() - .totalAmount(BigDecimal.valueOf(100000)) - .isExchanged(true) - .exchangeRate(BigDecimal.valueOf(1393.7)) - .foreignCurrency(CurrencyCode.USD) - .build(); + BudgetCreateRequest request = new BudgetCreateRequest(BigDecimal.valueOf(100000), true, + BigDecimal.valueOf(1393.7), CurrencyCode.USD); + Budget budget = Budget.builder() - .teamId(teamId) - .totalAmount(request.getTotalAmount()) + .totalAmount(request.totalAmount()) .setBy(loginMemberId) - .balance(request.getTotalAmount()) - .foreignCurrency(request.getForeignCurrency()) + .balance(request.totalAmount()) + .foreignCurrency(request.foreignCurrency()) .build(); - budget.setExchangeInfo(request.getIsExchanged(), + budget.setExchangeInfo(request.isExchanged(), budget.getTotalAmount(), - request.getExchangeRate()); - BudgetCreateResponse expectedResponse = BudgetCreateResponse.builder() - .id(1L) - .balance(BigDecimal.valueOf(100000)) - .foreignBalance(BigDecimal.valueOf(71.75)) - .avgExchangeRate(BigDecimal.valueOf(1393.7)) - .build(); + request.exchangeRate()); + BudgetCreateResponse expectedResponse = new BudgetCreateResponse(1L, LocalDateTime.now(), + loginMemberId, BigDecimal.valueOf(100000), BigDecimal.valueOf(1393.7), + BigDecimal.valueOf(71.75)); when(budgetMapper.toCreateResponse(any(Budget.class))).thenReturn(expectedResponse); @@ -78,7 +73,7 @@ void save_create_exchanged_budget_and_return_create_response() throws Exception verify(budgetMapper).toCreateResponse(any(Budget.class)); assertThat(response).isNotNull(); - assertThat(response.getBalance()).isEqualTo(expectedResponse.getBalance()); + assertThat(response.balance()).isEqualTo(expectedResponse.balance()); } @@ -89,21 +84,16 @@ void getByTeamId_read_budget_by_team_id_and_return_read_response() throws Except // given Long teamId = 1L; Budget budget = Budget.builder() - .teamId(1L) .totalAmount(BigDecimal.valueOf(100000)) .balance(BigDecimal.valueOf(100000)) .foreignCurrency(CurrencyCode.USD) .foreignBalance(BigDecimal.valueOf(71.75)) .avgExchangeRate(BigDecimal.valueOf(1393.7)) .build(); - BudgetReadResponse expectedResponse = BudgetReadResponse.builder() - .id(1L) - .totalAmount(BigDecimal.valueOf(100000)) - .balance(BigDecimal.valueOf(100000)) - .foreignCurrency(CurrencyCode.USD) - .foreignBalance(BigDecimal.valueOf(71.75)) - .avgExchangeRate(BigDecimal.valueOf(1393.70)) - .build(); + + BudgetReadResponse expectedResponse = new BudgetReadResponse(1L, LocalDateTime.now(), 1L, + BigDecimal.valueOf(100000), BigDecimal.valueOf(100000), CurrencyCode.USD, + BigDecimal.valueOf(1393.70), BigDecimal.valueOf(71.75)); when(budgetValidator.validateBudgetExist(teamId)).thenReturn(budget); when(budgetMapper.toReadResponse(budget)).thenReturn(expectedResponse); @@ -124,24 +114,19 @@ void updateByTeamId_update_budget_and_return_update_response() throws Exception Long teamId = 1L; Long loginMemberId = 1L; Budget budget = Budget.builder() - .teamId(teamId) .totalAmount(BigDecimal.valueOf(100000)) .balance(BigDecimal.valueOf(100000)) .foreignCurrency(CurrencyCode.USD) .foreignBalance(BigDecimal.valueOf(71.75)) .avgExchangeRate(BigDecimal.valueOf(1393.7)) .build(); - BudgetUpdateRequest request = BudgetUpdateRequest.builder() - .totalAmount(BigDecimal.valueOf(150000)) - .build(); - BudgetUpdateResponse expectedResponse = BudgetUpdateResponse.builder() - .id(1L) - .setBy(loginMemberId) - .balance(BigDecimal.valueOf(150000)) - .foreignBalance(BigDecimal.valueOf(107.63)) - .foreignCurrency(CurrencyCode.USD) - .avgExchangeRate(BigDecimal.valueOf(1393.7)) - .build(); + BudgetUpdateRequest request = new BudgetUpdateRequest(BigDecimal.valueOf(150000), + false, null, null); + + BudgetUpdateResponse expectedResponse = new BudgetUpdateResponse(1L, LocalDateTime.now(), + loginMemberId, BigDecimal.valueOf(150000), CurrencyCode.USD, BigDecimal.valueOf(1393.7), + BigDecimal.valueOf(107.63)); + when(budgetValidator.validateBudgetExist(teamId)).thenReturn(budget); when(budgetMapper.toUpdateResponse(any(Budget.class))).thenReturn(expectedResponse); @@ -151,11 +136,11 @@ void updateByTeamId_update_budget_and_return_update_response() throws Exception // then assertThat(response).isNotNull(); - assertThat(response.getBalance()).isEqualTo(expectedResponse.getBalance()); - assertThat(response.getForeignBalance()).isEqualTo(expectedResponse.getForeignBalance()); + assertThat(response.balance()).isEqualTo(expectedResponse.balance()); + assertThat(response.foreignBalance()).isEqualTo(expectedResponse.foreignBalance()); - assertThat(budget.getTotalAmount()).isEqualTo(request.getTotalAmount()); - assertThat(budget.getBalance()).isEqualTo(request.getTotalAmount()); + assertThat(budget.getTotalAmount()).isEqualTo(request.totalAmount()); + assertThat(budget.getBalance()).isEqualTo(request.totalAmount()); } @Test @@ -165,7 +150,6 @@ void deleteByTeamId_delete_budget() throws Exception { // given Long teamId = 1L; Budget budget = Budget.builder() - .teamId(2L) .totalAmount(BigDecimal.valueOf(100000)) .balance(BigDecimal.valueOf(100000)) .foreignCurrency(CurrencyCode.USD) diff --git a/Backend/src/test/java/com/luckyseven/backend/domain/budget/validator/BudgetValidatorTests.java b/Backend/src/test/java/com/luckyseven/backend/domain/budget/validator/BudgetValidatorTests.java index 4eeb459c..b0c3d6a0 100644 --- a/Backend/src/test/java/com/luckyseven/backend/domain/budget/validator/BudgetValidatorTests.java +++ b/Backend/src/test/java/com/luckyseven/backend/domain/budget/validator/BudgetValidatorTests.java @@ -5,7 +5,6 @@ import static org.mockito.Mockito.when; import com.luckyseven.backend.domain.budget.dao.BudgetRepository; -import com.luckyseven.backend.domain.budget.dto.BudgetBaseRequest; import com.luckyseven.backend.domain.budget.entity.Budget; import com.luckyseven.backend.domain.budget.entity.CurrencyCode; import com.luckyseven.backend.sharedkernel.exception.CustomLogicException; @@ -33,7 +32,6 @@ void validateBudgetNotExist_throw_exception_when_budget_already_exists() throws // given Long teamId = 1L; Budget budget = Budget.builder() - .teamId(1L) .totalAmount(BigDecimal.valueOf(100000)) .setBy(1L) .balance(BigDecimal.valueOf(100000)) @@ -69,24 +67,4 @@ void validateBudgetExist_throw_exception_when_budget_does_not_exist() throws Exc ).isInstanceOf(CustomLogicException.class); } - - @Test - @DisplayName("validateRequest는 환전 여부가 true일 때 환율을 입력하지 않으면 오류가 발생한다") - void validateRequest_throw_exception_when_isExchanged_true_but_exchange_rate_no_exist() - throws Exception { - - // given - BudgetBaseRequest request = BudgetBaseRequest.builder() - .totalAmount(BigDecimal.valueOf(100000)) - .isExchanged(true) - .build(); - - // when & then - assertThatThrownBy( - () -> { - budgetValidator.validateRequest(request); - } - ).isInstanceOf(CustomLogicException.class); - - } } \ No newline at end of file diff --git a/Backend/src/test/java/com/luckyseven/backend/domain/settlements/app/SettlementServiceTest.java b/Backend/src/test/java/com/luckyseven/backend/domain/settlements/app/SettlementServiceTest.java index 5d3e969f..1ac8feca 100644 --- a/Backend/src/test/java/com/luckyseven/backend/domain/settlements/app/SettlementServiceTest.java +++ b/Backend/src/test/java/com/luckyseven/backend/domain/settlements/app/SettlementServiceTest.java @@ -100,7 +100,7 @@ void createSettlement_ShouldSaveSettlement() { @Test @DisplayName("정산롼료") - void setSettled_ShouldUpdateSettlementStatus() { + void convertSettled_ShouldUpdateSettlementStatus() { //given when(settlementRepository.findWithSettlerAndPayerById(anyLong())).thenReturn( Optional.of(settlement)); diff --git a/Backend/src/test/java/com/luckyseven/backend/domain/settlements/dao/SettlementRepositoryTest.java b/Backend/src/test/java/com/luckyseven/backend/domain/settlements/dao/SettlementRepositoryTest.java index 4d1e34d2..623e30b9 100644 --- a/Backend/src/test/java/com/luckyseven/backend/domain/settlements/dao/SettlementRepositoryTest.java +++ b/Backend/src/test/java/com/luckyseven/backend/domain/settlements/dao/SettlementRepositoryTest.java @@ -150,7 +150,7 @@ void setUp() { .expense(i < 15 ? expense1 : expense2) .build(); if (i % 2 == 0) { - settlement.setSettled(); + settlement.convertSettled(); } settlementRepository.save(settlement); } diff --git a/Frontend/luckeyseven/src/App.jsx b/Frontend/luckeyseven/src/App.jsx index d279be12..3d250eb9 100644 --- a/Frontend/luckeyseven/src/App.jsx +++ b/Frontend/luckeyseven/src/App.jsx @@ -8,7 +8,6 @@ import { import Login from "./pages/Login/Login" import Signup from "./pages/Login/Signup" import Home from "./pages/Home" -import {HomePage as SettlementHomePage} from "./pages/Settlement/HomePage" import {TeamSettlementsPage} from "./pages/Settlement/TeamSettlementsPage" import {SettlementNewPage} from "./pages/Settlement/SettlementNewPage" import {SettlementEditPage} from "./pages/Settlement/SettlementEditPage" @@ -19,8 +18,6 @@ import {getCurrentUser} from "./service/AuthService" import TeamDashBoard from "./pages/TeamDashBoard"; import TeamSetup from "./pages/TeamSetup" import {ToastProvider} from "./context/ToastContext" -import SettlementPage from "./pages/SettlementPage"; -import ExpensesPage from "./pages/ExpensesPage"; // 보호된 라우트 컴포넌트 const ProtectedRoute = ({children}) => { @@ -29,7 +26,7 @@ const ProtectedRoute = ({children}) => { if (!user) { // 로그인되지 않은 경우 로그인 페이지로 리다이렉트 return ; - } + } return children; }; @@ -51,19 +48,8 @@ function App() { } /> }/> - {/*settlement 임시 페이지 import 삭제로 인한 warning. 병합된 페이지로 변경 예정*/} - }/> - {/*expenses 임시 페이지 import 삭제로 인한 warning. 병합된 페이지로 변경 예정*/} - }/> + {/* Settlement 관련 라우트 */} - - - - } - /> @@ -89,7 +75,7 @@ function App() { } /> diff --git a/Frontend/luckeyseven/src/components/OverviewTabContent.js b/Frontend/luckeyseven/src/components/OverviewTabContent.js index 53db4d97..3c7ba331 100644 --- a/Frontend/luckeyseven/src/components/OverviewTabContent.js +++ b/Frontend/luckeyseven/src/components/OverviewTabContent.js @@ -1,41 +1,41 @@ import React from 'react'; -import styles from '../styles/App.module.css'; // Keep App.module.css for general layout styles +import styles from '../styles/App.module.css'; import SummaryCard from '../components/SummaryCard'; import BudgetBreakdown from '../components/BudgetBreakdown'; import RecentExpensesTable from '../components/RecentExpenseTable'; const OverviewTabContent = ({ dashboardData }) => { if (!dashboardData) { - return
Loading overview...
; // Or some other loading indicator + return
Loading overview...
; } - // Adapt data for child components const { team_id, - totalAmount, - balance, - foreignBalance, - foreignCurrency, - expenseList, // This is List - avgExchangeRate + teamName, + totalAmount = 0, // 기본값 설정 + balance = 0, // 기본값 설정 + foreignBalance = 0, // 기본값 설정 + foreignCurrency = 'KRW', // 기본값 설정 + expenseList = [], // 기본값 설정 + avgExchangeRate = 0, // 기본값 설정 } = dashboardData; - // Calculate percentages for SummaryCard if needed const totalExpense = totalAmount - balance; const totalExpensePercentage = totalAmount > 0 ? (totalExpense / totalAmount) * 100 : 0; const remainingBudgetPercentage = totalAmount > 0 ? (balance / totalAmount) * 100 : 0; - const transformedExpenses = expenseList.map(expense => ({ + + // 지출 목록이 없는 경우 빈 배열로 처리 + const transformedExpenses = Array.isArray(expenseList) ? expenseList.map(expense => ({ id: expense.id, - title: `Expense by ${expense.payerNickname}`, - amount: parseFloat(expense.amount), + title: `Expense by ${expense.payerNickname}`, + amount: parseFloat(expense.amount), category: expense.category, description: expense.description, - date: expense.date, + date: expense.date, paidBy: expense.payerNickname, - paymentMethod: expense.paymentMethod, - currency: expense.paymentMethod == "CASH" ? '₩': foreignCurrency, // Assuming KRW for now, adjust if dynamic - })); - + paymentMethod: expense.paymentMethod, + currency: expense.paymentMethod === "CASH" ? '₩' : foreignCurrency, + })) : []; return (
@@ -43,18 +43,15 @@ const OverviewTabContent = ({ dashboardData }) => { - {foreignCurrency && foreignBalance !== undefined && ( + {foreignCurrency && foreignBalance !== undefined && foreignCurrency !== 'KRW' && ( )}
- {/* BudgetBreakdown might need significant changes based on how categories are handled now */}
@@ -62,4 +59,4 @@ const OverviewTabContent = ({ dashboardData }) => { ); }; -export default OverviewTabContent; +export default OverviewTabContent; \ No newline at end of file diff --git a/Frontend/luckeyseven/src/components/PageHeaderControls.js b/Frontend/luckeyseven/src/components/PageHeaderControls.js index ebc3eff4..4da2b7a4 100644 --- a/Frontend/luckeyseven/src/components/PageHeaderControls.js +++ b/Frontend/luckeyseven/src/components/PageHeaderControls.js @@ -1,21 +1,94 @@ import React from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; import styles from '../styles/PageHeaderControls.module.css'; +import { useRecoilValue } from "recoil"; +import { currentTeamIdState } from "../recoil/atoms/teamAtoms"; + +const PageHeaderControls = ({ pageHeaderData, onBudgetDelete }) => { + const navigate = useNavigate(); + const teamId = useRecoilValue(currentTeamIdState); + const { teamName, openDialog } = pageHeaderData || {}; + + const handleSetBudget = () => { + if (typeof openDialog === 'function') { + openDialog('set'); + } else { + console.error('openDialog is not a function'); + } + }; + + const handleEditBudget = () => { + if (typeof openDialog === 'function') { + openDialog('edit'); + } else { + console.error('openDialog is not a function'); + } + }; + + const handleAddBudget = () => { + if (typeof openDialog === 'function') { + openDialog('add'); + } else { + console.error('openDialog is not a function'); + } + }; + + const handleDeleteBudget = async () => { + if (!teamId) { + console.error('teamId is missing'); + return; + } + + // 사용자에게 삭제 확인 요청 + if (!window.confirm('정말로 예산을 삭제하시겠습니까?')) { + return; + } + + try { + const response = await axios.delete(`/api/teams/${teamId}/budget`); + + if (response.status === 204) { + console.log('예산 삭제 완료'); + alert('예산이 성공적으로 삭제되었습니다.'); + + // 예산 삭제 후 부모 컴포넌트에 알림 + if (typeof onBudgetDelete === 'function') { + onBudgetDelete(); + } + } else { + alert('예산 삭제에 실패했습니다.'); + } + } catch (error) { + console.error('예산 삭제 실패:', error); + + // 404 에러는 이미 삭제된 경우이므로 성공으로 처리 + if (error.response && error.response.status === 404) { + console.log('이미 삭제된 예산'); + if (typeof onBudgetDelete === 'function') { + onBudgetDelete(); + } + } else { + // 다른 에러는 사용자에게 알림 + alert('예산 삭제 실패: ' + (error.response?.data?.message || error.message)); + } + } + }; -const PageHeaderControls = ({pageHeaderData}) => { - console.log('PageHeaderControls props ->', { pageHeaderData }); - const { teamName } = pageHeaderData; return ( -
-
-

{teamName}

-

Manage your team's expenses and budget

-
-
- - -
+
+
+

{teamName || 'Team Dashboard'}

+

Manage your team's expenses and budget

+
+
+ + + +
+
); }; -export default PageHeaderControls; +export default PageHeaderControls; \ No newline at end of file diff --git a/Frontend/luckeyseven/src/components/SummaryCard.js b/Frontend/luckeyseven/src/components/SummaryCard.js index 17c19bf3..47d7e9d5 100644 --- a/Frontend/luckeyseven/src/components/SummaryCard.js +++ b/Frontend/luckeyseven/src/components/SummaryCard.js @@ -7,7 +7,7 @@ const SummaryCard = ({ title, amount, currency, percentage, of }) => { return (

{title}

-

{currency}{amount.toLocaleString()}

+

{currency}{amount != null ? amount.toLocaleString() : '0'}

{percentage &&

{percentage}% {of}

}
); diff --git a/Frontend/luckeyseven/src/components/Tabs.js b/Frontend/luckeyseven/src/components/Tabs.js index 5e5fed65..24dc4828 100644 --- a/Frontend/luckeyseven/src/components/Tabs.js +++ b/Frontend/luckeyseven/src/components/Tabs.js @@ -13,14 +13,14 @@ const Tabs = ({ activeTab, setActiveTab }) => { switch (tab) { case 'Expenses': - return `/team/${teamId}/expenses`; + return `/teams/${teamId}/expenses`; case 'Settlement': - return `/team/${teamId}/settlement`; + return '/settlement' + // return `/teams/${teamId}/settlements`; default: return '#'; // Overview and Members will still use setActiveTab } }; - return (
{tabs.map(tab => { diff --git a/Frontend/luckeyseven/src/components/common/SettlementActions.jsx b/Frontend/luckeyseven/src/components/common/SettlementActions.jsx index 682c010e..5a207ad5 100644 --- a/Frontend/luckeyseven/src/components/common/SettlementActions.jsx +++ b/Frontend/luckeyseven/src/components/common/SettlementActions.jsx @@ -1,11 +1,11 @@ "use client" -import { useState } from "react" -import { updateSettlement } from "../../service/settlementService" -import { useToast } from "../../context/ToastContext" +import {useState} from "react" +import {updateSettlement} from "../../service/settlementService" +import {useToast} from "../../context/ToastContext" -export function SettlementActions({ settlement, onUpdate, inline = false }) { - const { addToast } = useToast() +export function SettlementActions({settlement, onUpdate, inline = false}) { + const {addToast} = useToast() const [isLoading, setIsLoading] = useState(false) const handleMarkAsSettled = async (e) => { @@ -14,7 +14,9 @@ export function SettlementActions({ settlement, onUpdate, inline = false }) { e.stopPropagation() } - if (settlement.isSettled) return + if (settlement.isSettled) { + return + } try { setIsLoading(true) @@ -50,11 +52,14 @@ export function SettlementActions({ settlement, onUpdate, inline = false }) { e.stopPropagation() } - if (!settlement.isSettled) return + if (!settlement.isSettled) { + return + } try { setIsLoading(true) - const updatedSettlement = await updateSettlement(settlement.id, { isSettled: false }) + const updatedSettlement = await updateSettlement(settlement.id, + {}, true) addToast({ title: "정산 완료 취소", @@ -83,16 +88,18 @@ export function SettlementActions({ settlement, onUpdate, inline = false }) { const btnClass = inline ? "btn-sm" : "" return ( -
- {settlement.isSettled ? ( - - ) : ( - - )} -
+
+ {settlement.isSettled ? ( + + ) : ( + + )} +
) } diff --git a/Frontend/luckeyseven/src/components/common/UserProfile.jsx b/Frontend/luckeyseven/src/components/common/UserProfile.jsx index 49d9b7bd..709faa39 100644 --- a/Frontend/luckeyseven/src/components/common/UserProfile.jsx +++ b/Frontend/luckeyseven/src/components/common/UserProfile.jsx @@ -1,17 +1,19 @@ -export function UserProfile({ user, label }) { +export function UserProfile({nickname, label}) { return ( -
-

{label}

-
- {user?.avatar ? ( - {user.name} - ) : ( -
- {user?.name.charAt(0) || "?"} -
- )} - {user?.name || "알 수 없음"} +
+

{label}

+
+ {nickname ? ( + {nickname} + ) : ( +
+ {nickname.charAt(0) || "?"} +
+ )} + {nickname || "알 수 없음"} +
-
) } diff --git a/Frontend/luckeyseven/src/components/settlement/settlement-detail.jsx b/Frontend/luckeyseven/src/components/settlement/settlement-detail.jsx index bd3e2d8d..2f498916 100644 --- a/Frontend/luckeyseven/src/components/settlement/settlement-detail.jsx +++ b/Frontend/luckeyseven/src/components/settlement/settlement-detail.jsx @@ -1,13 +1,13 @@ "use client" -import { useState } from "react" -import { useNavigate } from "react-router-dom" -import { formatDate, formatCurrency } from "../../lib/utils" -import { StatusBadge } from "../common/StatusBadge" -import { UserProfile } from "../common/UserProfile" -import { SettlementActions } from "../common/SettlementActions" +import {useState} from "react" +import {useNavigate} from "react-router-dom" +import {formatCurrency, formatDate} from "../../lib/utils" +import {StatusBadge} from "../common/StatusBadge" +import {UserProfile} from "../common/UserProfile" +import {SettlementActions} from "../common/SettlementActions" -export function SettlementDetail({ settlement: initialSettlement }) { +export function SettlementDetail({settlement: initialSettlement}) { const navigate = useNavigate() const [settlement, setSettlement] = useState(initialSettlement) @@ -16,67 +16,73 @@ export function SettlementDetail({ settlement: initialSettlement }) { } const handleEdit = () => { - navigate(`/settlements/${settlement.id}/edit`) + navigate(`/teams/${settlement.teamId}/settlements/${settlement.id}/edit`) } + console.info("팀아이디", settlement.teamId) return ( -
-
-
-

정산 #{settlement.id.substring(0, 8)}

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

정산 #{String(settlement.id).substring(0, + 8)}

+
- -
-
-

연관 지출

-

{settlement.expense?.title || "알 수 없음"}

- {settlement.expense && ( -

- {formatDate(settlement.expense.date)} · {formatCurrency(settlement.expense.amount)} -

- )} +
+
+
+
+ +
-
-

정산 금액

-

{formatCurrency(settlement.amount)}

+
+
+

연관 지출

+

{settlement.expenseDescription + || "알 수 없음"}

+ {settlement.expense && ( +

+ {formatDate(settlement.expense.date)} · {formatCurrency( + settlement.expense.amount)} +

+ )} +
+ +
+

정산 금액

+

{formatCurrency( + settlement.amount)}

+
-
-
-

정산 정보

-
-
-

생성일

-

{formatDate(settlement.createdAt)}

-
-
-

수정일

-

{formatDate(settlement.updatedAt)}

+
+

정산 정보

+
+
+

생성일

+

{formatDate(settlement.createdAt)}

+
+
+

수정일

+

{formatDate(settlement.updatedAt)}

+
-
-
- -
- - +
+ + +
-
) } diff --git a/Frontend/luckeyseven/src/components/settlement/settlement-filter.jsx b/Frontend/luckeyseven/src/components/settlement/settlement-filter.jsx index 9f75fc0e..906b14fa 100644 --- a/Frontend/luckeyseven/src/components/settlement/settlement-filter.jsx +++ b/Frontend/luckeyseven/src/components/settlement/settlement-filter.jsx @@ -1,9 +1,9 @@ "use client" -import { useState } from "react" -import { useNavigate, useLocation } from "react-router-dom" +import {useState} from "react" +import {useLocation, useNavigate} from "react-router-dom" -export function SettlementFilter({ users, expenses, initialFilters, teamId }) { +export function SettlementFilter({users, expenses, initialFilters}) { const navigate = useNavigate() const location = useLocation() @@ -15,7 +15,7 @@ export function SettlementFilter({ users, expenses, initialFilters, teamId }) { }) const handleFilterChange = (key, value) => { - setFilters((prev) => ({ ...prev, [key]: value })) + setFilters((prev) => ({...prev, [key]: value})) } const applyFilters = () => { @@ -44,98 +44,99 @@ export function SettlementFilter({ users, expenses, initialFilters, teamId }) { const hasActiveFilters = Object.values(filters).some((value) => value !== "") return ( -
-
-
-
- - -
+
+
+
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - +
+ + +
-
-
- {hasActiveFilters && ( - + )} + - )} - +
-
) } diff --git a/Frontend/luckeyseven/src/components/settlement/settlement-form.jsx b/Frontend/luckeyseven/src/components/settlement/settlement-form.jsx index 4534947c..e1e06f35 100644 --- a/Frontend/luckeyseven/src/components/settlement/settlement-form.jsx +++ b/Frontend/luckeyseven/src/components/settlement/settlement-form.jsx @@ -1,22 +1,30 @@ "use client" -import { useState, useEffect } from "react" -import { useNavigate } from "react-router-dom" -import { useToast } from "../../context/ToastContext" -import { formatCurrency } from "../../lib/utils" -import { createSettlement, updateSettlement } from "../../service/settlementService" - -export function SettlementForm({ settlement, users, expenses, isEditing = false }) { +import {useEffect, useState} from "react" +import {useNavigate} from "react-router-dom" +import {useToast} from "../../context/ToastContext" +import {formatCurrency} from "../../lib/utils" +import { + createSettlement, + updateSettlement +} from "../../service/settlementService" + +export function SettlementForm({ + settlement, + users, + expenses, + isEditing = false +}) { const navigate = useNavigate() - const { addToast } = useToast() + const {addToast} = useToast() const [isLoading, setIsLoading] = useState(false) const [formData, setFormData] = useState({ - payerId: "", - settlerId: "", - expenseId: "", - amount: "", - isSettled: false, + payerId: isEditing ? settlement.payerId : "", + settlerId: isEditing ? settlement.settlerId : "", + expenseId: isEditing ? settlement.expenseId : "", + amount: isEditing ? settlement.amount.toString() : "", + isSettled: isEditing ? settlement.isSettled : false }) // 수정 모드일 경우 초기 데이터 설정 @@ -44,7 +52,8 @@ export function SettlementForm({ settlement, users, expenses, isEditing = false // 지출 항목이 변경되면 금액도 자동으로 업데이트 if (expenseId) { - const selectedExpense = expenses.find((expense) => expense.id === expenseId) + const selectedExpense = expenses.find( + (expense) => expense.id === expenseId) if (selectedExpense) { // 기본적으로 지출 금액의 절반으로 설정 const defaultAmount = Math.floor(selectedExpense.amount / 2) @@ -55,8 +64,8 @@ export function SettlementForm({ settlement, users, expenses, isEditing = false const handleSubmit = async (e) => { e.preventDefault() - - if (!formData.payerId || !formData.settlerId || !formData.expenseId || !formData.amount) { + if (!formData.payerId || !formData.settlerId || !formData.expenseId + || !formData.amount) { addToast({ title: "입력 오류", description: "모든 필수 항목을 입력해주세요.", @@ -91,7 +100,8 @@ export function SettlementForm({ settlement, users, expenses, isEditing = false addToast({ title: isEditing ? "정산 수정 완료" : "정산 생성 완료", - description: isEditing ? "정산 내역이 성공적으로 수정되었습니다." : "새로운 정산 내역이 생성되었습니다.", + description: isEditing ? "정산 내역이 성공적으로 수정되었습니다." + : "새로운 정산 내역이 생성되었습니다.", }) // 수정 완료 후 상세 페이지로 이동 @@ -109,127 +119,134 @@ export function SettlementForm({ settlement, users, expenses, isEditing = false } // 선택된 지출 항목 정보 - const selectedExpense = formData.expenseId ? expenses.find((expense) => expense.id === formData.expenseId) : null + const selectedExpense = formData.expenseId ? expenses.find( + (expense) => expense.id === formData.expenseId) : null return ( -
-
-
-
-
-
- - -
+ +
+
+
+
+
+ + +
-
- -
- - +
+ +
+ + +
-
-
-
- - - -
- {selectedExpense ? ( -

총 지출 금액: {formatCurrency(selectedExpense.amount)}

- ) : ( -

총 지출 금액: ₩0

- )} -
+
+
+ + + +
+ {selectedExpense ? ( +

총 지출 + 금액: {formatCurrency(selectedExpense.amount)}

+ ) : ( +

총 지출 금액: ₩0

+ )} +
-
+
-
- - handleChange("amount", e.target.value)} - placeholder="정산 금액 입력" - disabled={isLoading} - /> -
+
+ + handleChange("amount", e.target.value)} + placeholder="정산 금액 입력" + disabled={isLoading} + /> +
-
- handleChange("isSettled", e.target.checked)} - disabled={isLoading} - /> - +
+ handleChange("isSettled", + e.target.checked)} + disabled={isLoading} + /> + +
-
-
- - +
+ + +
-
- + ) } diff --git a/Frontend/luckeyseven/src/components/settlement/settlement-list.jsx b/Frontend/luckeyseven/src/components/settlement/settlement-list.jsx index 6e7dae35..332a1da3 100644 --- a/Frontend/luckeyseven/src/components/settlement/settlement-list.jsx +++ b/Frontend/luckeyseven/src/components/settlement/settlement-list.jsx @@ -1,77 +1,89 @@ "use client" -import { useState } from "react" -import { Link } from "react-router-dom" -import { formatDate, formatCurrency } from "../../lib/utils" -import { StatusBadge } from "../common/StatusBadge" -import { SettlementActions } from "../common/SettlementActions" +import {useState} from "react" +import {Link} from "react-router-dom" +import {formatCurrency, formatDate} from "../../lib/utils" +import {StatusBadge} from "../common/StatusBadge" +import {SettlementActions} from "../common/SettlementActions" -export function SettlementList({ settlements: initialSettlements }) { +export function SettlementList({settlements: initialSettlements}) { const [settlements, setSettlements] = useState(initialSettlements) const handleUpdate = (updatedSettlement) => { setSettlements((prevSettlements) => - prevSettlements.map((settlement) => (settlement.id === updatedSettlement.id ? updatedSettlement : settlement)), + prevSettlements.map( + (settlement) => (settlement.id === updatedSettlement.id + ? updatedSettlement : settlement)), ) } if (settlements.length === 0) { return ( -
-
-

조회된 정산 내역이 없습니다.

-

필터를 변경하거나 새로운 정산을 추가해보세요.

+
+
+

조회된 정산 내역이 없습니다.

+

필터를 변경하거나 새로운 정산을 추가해보세요.

+
-
) } return ( -
- {settlements.map((settlement) => ( - -
-
-
-
-
-

정산 #{settlement.id.substring(0, 8)}

- -
+
+ {settlements.map((settlement) => ( + +
+
+
+
+
+

정산 #{String( + settlement.id).substring(0, 8)}

+ +
-
- 생성일: {formatDate(settlement.createdAt)} -
+
+ 생성일: {formatDate(settlement.createdAt)} +
-
-
-

결제자

-

{settlement.payer?.name || "알 수 없음"}

-
-
-

정산자

-

{settlement.settler?.name || "알 수 없음"}

-
-
-

지출 항목

-

{settlement.expense?.title || "알 수 없음"}

+
+
+

결제자

+

{settlement.payerNickname + || "알 수 없음"}

+
+
+

정산자

+

{settlement.settlerNickname + || "알 수 없음"}

+
+
+

지출 항목

+

{settlement.expenseDescription + || "알 수 없음"}

+
+
-
-
-
-

{formatCurrency(settlement.amount)}

+
+

{formatCurrency( + settlement.amount)}

-
- - +
+ + +
+
-
-
- - ))} -
+ + ))} +
) } diff --git a/Frontend/luckeyseven/src/index.css b/Frontend/luckeyseven/src/index.css index c6c00dda..e721fd36 100644 --- a/Frontend/luckeyseven/src/index.css +++ b/Frontend/luckeyseven/src/index.css @@ -8,29 +8,29 @@ --color-primary: #3b82f6; --color-primary-rgb: 59, 130, 246; --color-primary-foreground: white; - + --color-primary-hover: #2563eb; - + --color-secondary: #f3f4f6; --color-secondary-rgb: 243, 244, 246; --color-secondary-foreground: #4b5563; - + --color-destructive: #e74c3c; --color-destructive-rgb: 231, 76, 60; --color-destructive-foreground: white; - + --color-accent: #f8f9fa; --color-accent-foreground: #1f2937; - + --color-background: white; --color-card: white; --color-card-foreground: #1f2937; - + --color-border: #eaeaea; --color-input-border: #d1d5db; --color-muted-foreground: #4b5563; --color-ring: #3b82f6; - + --color-error-bg: #fde2e2; } @@ -68,6 +68,8 @@ a { transition-property: color, background-color, border-color; transition-duration: 150ms; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + margin-left: 5px; + margin-right: 5px; } .btn:focus-visible { @@ -175,7 +177,6 @@ a { .card-content { padding: 1.5rem; - padding-top: 0; } .card-footer { @@ -278,12 +279,8 @@ a { .badge-outline { border-color: var(--color-border); - background-color: transparent; - color: var(--color-card-foreground); -} - -.badge-outline:hover { - background-color: rgba(0, 0, 0, 0.05); + background-color: rgba(74, 173, 61, 0.77); + color: white; } /* 레이아웃 */ diff --git a/Frontend/luckeyseven/src/pages/BudgetPage/BudgetPage.jsx b/Frontend/luckeyseven/src/pages/BudgetPage/BudgetPage.jsx new file mode 100644 index 00000000..0e2cd200 --- /dev/null +++ b/Frontend/luckeyseven/src/pages/BudgetPage/BudgetPage.jsx @@ -0,0 +1,164 @@ +import React, { useEffect, useState } from "react"; +import axios from 'axios'; +import { useParams } from "react-router-dom"; +import SetBudgetDialog from "./components/set-budget-dialog"; +import AddBudgetDialog from "./components/add-budget-dialog"; +import PageHeaderControls from "../../components/PageHeaderControls"; +import { setBudgetInitialized } from "../../service/ApiService"; +import { currentTeamIdState } from "../../recoil/atoms/teamAtoms"; +import { useRecoilValue } from "recoil"; +import { SafeFormatterUtil } from './utils/SafeFormatterUtil'; + +export function BudgetPage() { + const teamId = useRecoilValue(currentTeamIdState); + const [dialogType, setDialogType] = useState(null); // 'set' | 'edit' | 'add' | null + const [budget, setBudget] = useState(null); + const [loading, setLoading] = useState(true); + + const token = localStorage.getItem("accessToken"); + + const handleClose = () => setDialogType(null); + + const handleUpdate = async (updatePayload) => { + try { + const res = await fetch(`/api/teams/${teamId}/budget`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify(updatePayload), + }); + + if (!res.ok) { + if (res.status === 404) { + alert("예산 정보를 찾을 수 없습니다."); + } else { + alert("예산 업데이트에 실패했습니다."); + } + return; + } + + const updatedBudget = await res.json(); + setBudget(updatedBudget); + setBudgetInitialized(true); // ApiService의 예산 초기화 상태 업데이트 + setDialogType(null); + } catch (err) { + alert("서버와 통신 중 오류가 발생했습니다."); + console.error(err); + } + }; + + // 예산 삭제 처리 함수 + const handleBudgetDelete = async () => { + try { + // 예산 상태 즉시 초기화 (UI 업데이트) + setBudget(null); + setBudgetInitialized(false); + + // 다이얼로그 닫기 + setDialogType(null); + + // 새로운 예산 정보 불러오기 (404가 예상됨) + await fetchBudget(); + } catch (err) { + console.error("예산 삭제 후 상태 업데이트 실패:", err); + } + }; + + const fetchBudget = async () => { + try { + setLoading(true); + + const res = await axios.get(`/api/teams/${teamId}/budget`, { + headers: { + "Authorization": `Bearer ${token}`, + } + }); + + if (res.status === 200) { + setBudget(res.data); + setBudgetInitialized(true); + } + } catch (err) { + console.error("예산 정보를 불러오는 데 실패했습니다:", err); + + // 404 에러인 경우 예산이 없는 것으로 처리 + if (err.response && err.response.status === 404) { + setBudget(null); + setBudgetInitialized(false); + } + } finally { + setLoading(false); + } + }; + + // 예산 업데이트 처리 함수 + const handleBudgetUpdate = (updatedBudget) => { + setBudget(updatedBudget); + setBudgetInitialized(true); + setDialogType(null); + }; + + useEffect(() => { + fetchBudget(); + }, [teamId, token]); + + if (loading) return

불러오는 중...

; + + // 페이지 헤더 데이터 설정 + const pageHeaderData = { + teamName: budget?.team?.name || `팀 ${teamId}`, + teamId, + openDialog: setDialogType + }; + + return ( +
+ + +

+ [{budget?.team?.name || `팀 ${teamId}`}] 예산 +

+ + {budget ? ( +
+

총 예산: {SafeFormatterUtil.formatCurrency(budget?.totalAmount)} KRW

+

원화 잔고: {SafeFormatterUtil.formatCurrency(budget?.balance)} KRW

+

외화 잔고: {SafeFormatterUtil.formatCurrency(budget?.foreignBalance)} {budget?.foreignCurrency || 'KRW'}

+

평균 환율: {budget?.avgExchangeRate || 0}

+
+ ) : ( +
+

아직 설정된 예산이 없습니다. 예산을 설정해주세요.

+ +
+ )} + + {/* Dialogs */} + {dialogType === "set" && ( + + )} + + {dialogType === "add" && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/Frontend/luckeyseven/src/pages/BudgetPage/components/add-budget-dialog.jsx b/Frontend/luckeyseven/src/pages/BudgetPage/components/add-budget-dialog.jsx new file mode 100644 index 00000000..baaba4ae --- /dev/null +++ b/Frontend/luckeyseven/src/pages/BudgetPage/components/add-budget-dialog.jsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import axios from 'axios'; +import '../styles/BudgetDialog.css'; + +const AddBudgetDialog = ({ teamId, closeDialog, onBudgetUpdate }) => { + const [additionalBudget, setAdditionalBudget] = useState(0); + const [isExchanged, setIsExchanged] = useState(false); + const [exchangeRate, setExchangeRate] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const resetForm = () => { + setAdditionalBudget(0); + setIsExchanged(false); + setExchangeRate(''); + }; + + const handleClose = () => { + resetForm(); + closeDialog(); + }; + + const handleSubmit = async () => { + if (isSubmitting) return; + setIsSubmitting(true); + + try { + const response = await axios.patch(`/api/teams/${teamId}/budget`, { + additionalBudget, + isExchanged, + exchangeRate: isExchanged ? exchangeRate : null, + }); + console.log(response.data); + + if (onBudgetUpdate) { + onBudgetUpdate(response.data); + } + + resetForm(); + closeDialog(); + } catch (error) { + console.error('Error adding budget:', error); + alert('예산 추가 중 오류가 발생했습니다: ' + (error.response?.data?.message || error.message)); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
e.stopPropagation()}> +

예산 추가

+ + setAdditionalBudget(e.target.value)} + placeholder="추가할 예산 금액" + min = "0" + step = "100" + /> + +
+ + + +
+ + {isExchanged && ( + <> + + setExchangeRate(e.target.value)} + placeholder="환율" + min = "0" + /> + + )} + +
+ + +
+
+
+ ); +}; + +export default AddBudgetDialog; \ No newline at end of file diff --git a/Frontend/luckeyseven/src/pages/BudgetPage/components/edit-budget-dialog.jsx b/Frontend/luckeyseven/src/pages/BudgetPage/components/edit-budget-dialog.jsx new file mode 100644 index 00000000..77717efc --- /dev/null +++ b/Frontend/luckeyseven/src/pages/BudgetPage/components/edit-budget-dialog.jsx @@ -0,0 +1,145 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import '../styles/BudgetDialog.css'; + +const EditBudgetDialog = ({ teamId, budgetId, closeDialog, onBudgetUpdate }) => { + const [totalAmount, setTotalAmount] = useState(0); + const [isExchanged, setIsExchanged] = useState(false); + const [exchangeRate, setExchangeRate] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [initialLoaded, setInitialLoaded] = useState(false); + + useEffect(() => { + const fetchBudget = async () => { + try { + // If budgetId is not provided, try to fetch the team's budget + const url = budgetId + ? `/api/teams/${teamId}/budget/${budgetId}` + : `/api/teams/${teamId}/budget`; + + const response = await axios.get(url); + const budget = response.data; + setTotalAmount(budget.balance || budget.totalAmount || 0); + setIsExchanged(!!budget.isExchanged); + setExchangeRate(budget.avgExchangeRate || ''); + setInitialLoaded(true); + } catch (error) { + console.error('Error fetching budget:', error); + // If we can't fetch the budget data, set defaults + setTotalAmount(0); + setIsExchanged(false); + setExchangeRate(''); + setInitialLoaded(true); + } + }; + + fetchBudget(); + }, [teamId, budgetId]); + + const resetForm = () => { + setTotalAmount(0); + setIsExchanged(false); + setExchangeRate(''); + }; + + const handleClose = () => { + resetForm(); + closeDialog(); + }; + + const handleSubmit = async () => { + if (isSubmitting) return; + setIsSubmitting(true); + + try { + const response = await axios.patch(`/api/teams/${teamId}/budget`, { + totalAmount, + isExchanged, + exchangeRate: isExchanged ? exchangeRate : null, + }); + console.log(response.data); + + if (onBudgetUpdate) { + onBudgetUpdate(response.data); + } + + resetForm(); + closeDialog(); + } catch (error) { + console.error('Error updating budget:', error); + alert('예산 수정 중 오류가 발생했습니다: ' + (error.response?.data?.message || error.message)); + } finally { + setIsSubmitting(false); + } + }; + + // If we're still loading the initial data, show a loading indicator + if (!initialLoaded) { + return ( +
+
+

예산 정보 로딩 중...

+
+
+ ); + } + + return ( +
+
e.stopPropagation()}> +

예산 수정

+ + setTotalAmount(e.target.value)} + placeholder="수정할 예산 금액" + min = "0" + step = "100" + /> + +
+ + + +
+ + {isExchanged && ( + <> + + setExchangeRate(e.target.value)} + placeholder="환율" + min = "0" + /> + + )} + +
+ + +
+
+
+ ); +}; + +export default EditBudgetDialog; \ No newline at end of file diff --git a/Frontend/luckeyseven/src/pages/BudgetPage/components/set-budget-dialog.jsx b/Frontend/luckeyseven/src/pages/BudgetPage/components/set-budget-dialog.jsx new file mode 100644 index 00000000..a91c69d7 --- /dev/null +++ b/Frontend/luckeyseven/src/pages/BudgetPage/components/set-budget-dialog.jsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; +import axios from 'axios'; +import '../styles/BudgetDialog.css'; + +const SetBudgetDialog = ({ teamId, closeDialog, onBudgetUpdate }) => { + const [totalAmount, setTotalAmount] = useState(0); + const [isExchanged, setIsExchanged] = useState(false); + const [foreignCurrency, setForeignCurrency] = useState('KRW'); + const [exchangeRate, setExchangeRate] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(''); + + const resetForm = () => { + setTotalAmount(0); + setIsExchanged(false); + setForeignCurrency('USD'); + setExchangeRate(''); + setError(''); + }; + + const handleClose = () => { + resetForm(); + closeDialog(); + }; + + // 예산 설정 함수 - 간소화된 버전 + const handleSubmit = async () => { + if (isSubmitting) return; + setIsSubmitting(true); + setError(''); + + // 입력값 유효성 검사 + if (totalAmount <= 0) { + setError('예산 금액은 0보다 커야 합니다.'); + setIsSubmitting(false); + return; + } + + if (isExchanged && (!exchangeRate || exchangeRate <= 0)) { + setError('유효한 환율을 입력해주세요.'); + setIsSubmitting(false); + return; + } + + try { + // 기존 예산이 있다면 먼저 삭제 + try { + await axios.delete(`/api/teams/${teamId}/budget`); + console.log('기존 예산 삭제 완료'); + } catch (deleteError) { + // 404는 예산이 없는 경우이므로 무시 + if (deleteError.response && deleteError.response.status !== 404) { + console.warn('예산 삭제 시도 중 오류:', deleteError); + } + } + + // 새 예산 설정 + const response = await axios.post(`/api/teams/${teamId}/budget`, { + totalAmount, + isExchanged, + foreignCurrency, + exchangeRate: isExchanged ? exchangeRate : null, + }); + + console.log('Budget setup response:', response.data); + + if (onBudgetUpdate) { + onBudgetUpdate(response.data); + } + + resetForm(); + closeDialog(); // 다이얼로그 닫기 + } catch (error) { + console.error('Error setting budget:', error); + + if (error.response) { + setError('예산 설정 중 오류가 발생했습니다: ' + (error.response.data?.message || error.message)); + } else { + setError('서버와 통신 중 오류가 발생했습니다.'); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
e.stopPropagation()}> +

예산 설정

+ + {error &&
{error}
} + + + setTotalAmount(e.target.value)} + placeholder="예산 금액" + min="0" + step="100" + /> + + + +
+ + + +
+ + {isExchanged && ( + <> + + setExchangeRate(e.target.value)} + placeholder="환율" + min="0" + /> + + )} + +
+ + +
+
+
+ ); +}; + +export default SetBudgetDialog; \ No newline at end of file diff --git a/Frontend/luckeyseven/src/pages/BudgetPage/styles/BudgetDialog.css b/Frontend/luckeyseven/src/pages/BudgetPage/styles/BudgetDialog.css new file mode 100644 index 00000000..fefa26e2 --- /dev/null +++ b/Frontend/luckeyseven/src/pages/BudgetPage/styles/BudgetDialog.css @@ -0,0 +1,85 @@ +/* BudgetDialog.css */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + width: 400px; + max-width: 90%; + z-index: 1001; +} + +.modal h2 { + margin-top: 0; + margin-bottom: 20px; + color: #333; + font-size: 1.5rem; +} + +.modal input, .modal select { + width: 100%; + padding: 10px; + margin-bottom: 15px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +.modal-buttons { + display: flex; + justify-content: space-between; + margin-top: 20px; +} + +.modal button { + padding: 8px 16px; + margin: 5px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.modal button.primary { + background-color: #007bff; + color: white; +} + +.modal button.secondary { + background-color: #6c757d; + color: white; +} + +.modal button:hover { + opacity: 0.9; +} + +.modal .toggle-buttons { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.modal .toggle-buttons button { + flex: 1; + background-color: #f1f1f1; + color: #333; +} + +.modal .toggle-buttons button.active { + background-color: #007bff; + color: white; +} \ No newline at end of file diff --git a/Frontend/luckeyseven/src/pages/BudgetPage/utils/SafeFormatterUtil.js b/Frontend/luckeyseven/src/pages/BudgetPage/utils/SafeFormatterUtil.js new file mode 100644 index 00000000..d83fbd30 --- /dev/null +++ b/Frontend/luckeyseven/src/pages/BudgetPage/utils/SafeFormatterUtil.js @@ -0,0 +1,13 @@ +export const SafeFormatterUtil = { + formatCurrency: (value, fallback = '0') => { + return (value != null) ? value.toLocaleString() : fallback; + }, + + formatNumber: (value, fallback = '0') => { + return (value != null) ? value.toString() : fallback; + }, + + formatPercent: (value, fallback = '0%') => { + return (value != null) ? `${value}%` : fallback; + } +}; \ No newline at end of file diff --git a/Frontend/luckeyseven/src/pages/ExpenseDialog/AddExpenseDialog.jsx b/Frontend/luckeyseven/src/pages/ExpenseDialog/AddExpenseDialog.jsx index 75359ee0..592afef4 100644 --- a/Frontend/luckeyseven/src/pages/ExpenseDialog/AddExpenseDialog.jsx +++ b/Frontend/luckeyseven/src/pages/ExpenseDialog/AddExpenseDialog.jsx @@ -47,6 +47,7 @@ export default function AddExpenseDialog({ onClose, onSuccess }) { async function fetchMembers() { try { const members = await getTeamMembers(teamId); + console.log('members:', members); setUsers(members); if (members.length > 0) { const firstId = String(members[0].id); diff --git a/Frontend/luckeyseven/src/pages/ExpenseDialog/ExpenseList.jsx b/Frontend/luckeyseven/src/pages/ExpenseDialog/ExpenseList.jsx index 192f0b9f..2e2e0924 100644 --- a/Frontend/luckeyseven/src/pages/ExpenseDialog/ExpenseList.jsx +++ b/Frontend/luckeyseven/src/pages/ExpenseDialog/ExpenseList.jsx @@ -1,9 +1,16 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, {useState, useEffect, useCallback} from 'react'; +import {useNavigate} from 'react-router-dom'; import AddExpenseDialog from './AddExpenseDialog'; import ExpenseDetailDialog from './ExpenseDetailDialog'; import '../../components/styles/expenseList.css'; -import { getListExpense } from '../../service/ExpenseService'; -import { FaMoneyBillWave } from 'react-icons/fa'; +import {getListExpense} from '../../service/ExpenseService'; +import {FaMoneyBillWave} from 'react-icons/fa'; +import Header from '../../components/Header'; + +import {FiHome} from 'react-icons/fi'; +import {FiPlus} from 'react-icons/fi'; +import {useRecoilValue} from "recoil"; +import {currentTeamIdState} from "../../recoil/atoms/teamAtoms"; const CATEGORY_LABELS = { MEAL: '식사', @@ -13,11 +20,12 @@ const CATEGORY_LABELS = { MISCELLANEOUS: '기타', }; -export default function ExpenseList({ teamId = 1 }) { +export default function ExpenseList() { + const teamId = useRecoilValue(currentTeamIdState); + const navigate = useNavigate(); const [showAddDialog, setShowAddDialog] = useState(false); const [selectedExpenseId, setSelectedExpenseId] = useState(null); - const [expenses, setExpenses] = useState([]); const [page, setPage] = useState(0); const [size] = useState(10); @@ -26,15 +34,14 @@ export default function ExpenseList({ teamId = 1 }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // 서버에서 받은 잔고 배너 및 알림 상태 const [balances, setBalances] = useState(null); - const [notification, setNotification] = useState({ message: '', type: '' }); + const [notification, setNotification] = useState({message: '', type: ''}); - // 지출 목록 재조회 함수 const fetchExpenses = useCallback(async () => { setLoading(true); try { - const data = await getListExpense(teamId, page, size, `createdAt,${sortDirection}`); + const data = await getListExpense(teamId, page, size, + `createdAt,${sortDirection}`); setExpenses(data.content); setTotalPages(data.totalPages); setError(null); @@ -45,54 +52,53 @@ export default function ExpenseList({ teamId = 1 }) { } }, [teamId, page, size, sortDirection]); - // 초기 및 페이지/정렬 변경 시 데이터 로드 useEffect(() => { fetchExpenses(); }, [fetchExpenses]); - // 배너 및 알림 자동 숨김 (10초) useEffect(() => { - if (!balances && !notification.message) return; + if (!balances && !notification.message) { + return; + } const timer = setTimeout(() => { setBalances(null); - setNotification({ message: '', type: '' }); + setNotification({message: '', type: ''}); }, 10000); return () => clearTimeout(timer); }, [balances, notification]); - if (loading) return ( -
-
-

여행 경비 매니저

-
-
-
데이터를 불러오고 있습니다...
-
-
- ); + if (loading) { + return ( +
+
+
+
데이터를 불러오고 있습니다...
+
+
+ ); + } - if (error) return ( -
-
-

여행 경비 매니저

-
-
-
-

데이터를 불러오는 중 오류가 발생했습니다

-

{error.message}

+ if (error) { + return ( +
+
+
+
+

데이터를 불러오는 중 오류가 발생했습니다

+

{error.message}

+
+
-
-
- ); + ); + } const openDetail = (expenseId) => setSelectedExpenseId(expenseId); const closeDetail = () => setSelectedExpenseId(null); const goToPage = (pageNumber) => setPage(pageNumber - 1); - // 지출 추가 성공 콜백 (전체 리스트 재조회) const handleAddSuccess = async (newExpense, balancesObj) => { setBalances(balancesObj); - setNotification({ message: '지출이 성공적으로 등록되었습니다.', type: 'register' }); + setNotification({message: '지출이 성공적으로 등록되었습니다.', type: 'register'}); setShowAddDialog(false); try { await fetchExpenses(); @@ -101,32 +107,28 @@ export default function ExpenseList({ teamId = 1 }) { } }; - // 지출 수정 성공 콜백 const handleUpdateSuccess = (updatedExpense, balancesObj) => { setExpenses(prev => - prev.map(exp => - exp.id === updatedExpense.id - ? { ...updatedExpense } - : exp - ) + prev.map(exp => + exp.id === updatedExpense.id + ? {...updatedExpense} + : exp + ) ); setBalances(balancesObj); - setNotification({ message: '지출이 성공적으로 수정되었습니다.', type: 'update' }); + setNotification({message: '지출이 성공적으로 수정되었습니다.', type: 'update'}); closeDetail(); }; - // 지출 삭제 성공 콜백 const handleDeleteSuccess = (deletedId, balancesObj) => { setExpenses(prev => prev.filter(exp => exp.id !== deletedId)); setBalances(balancesObj); - setNotification({ message: '지출이 성공적으로 삭제되었습니다.', type: 'delete' }); + setNotification({message: '지출이 성공적으로 삭제되었습니다.', type: 'delete'}); closeDetail(); }; - // 숫자 또는 대체 문자열 반환 const fmt = (value) => (value != null ? value.toLocaleString() : '-'); - const formatDate = (dateString) => { const date = new Date(dateString); return date.toLocaleDateString('ko-KR', { @@ -138,109 +140,218 @@ export default function ExpenseList({ teamId = 1 }) { }; return ( -
-
-

여행 경비 매니저

-
- {/* 예산 수정, CSV 내보내기 등 버튼 추가 가능 */} -
-
+
+
+
+

+ + 지출 내역 +

-
-

- - 지출 내역 -

- - {(balances || notification.message) && ( -
- {balances && ( -
- 원화 잔고: - ₩{fmt(balances.balance)} -    - 외화 잔고: - ${fmt(balances.foreignBalance)} -
- )} - {notification.message && ( -
- {notification.message} + {(balances || notification.message) && ( +
+ {balances && ( +
+ 원화 잔고: + ₩{fmt(balances.balance)} +    + 외화 잔고: + ${fmt(balances.foreignBalance)} +
+ )} + +
+ +
+ {/* */} + +
+ + +
+ +
+
+ + {expenses.length === 0 ? ( +
+

지출 내역이 없습니다

+

'지출 추가' 버튼을 클릭하여 첫 지출을 등록해보세요.

+
+ ) : ( +
+ + + + + + + + + + + + {expenses.map(exp => ( + openDetail(exp.id)} + style={{cursor: 'pointer'}}> + + + + + + + ))} + +
제목가격 (KRW)카테고리날짜결제자
{exp.description}₩{exp.amount.toLocaleString()}{CATEGORY_LABELS[exp.category] + || exp.category}{formatDate(exp.createdAt)}{exp.payerNickname}
+
+ )}
- )} -
- )} - -
- -
- - -
- - -
- -
-
- - {expenses.length === 0 ? ( -
-

지출 내역이 없습니다

-

'지출 추가' 버튼을 클릭하여 첫 지출을 등록해보세요.

+ )} + +
+
+ + +
+ +
+ +
- ) : ( -
- - - - - - - - - - - - {expenses.map(exp => ( - openDetail(exp.id)} style={{ cursor: 'pointer' }}> - - - - - + + {expenses.length === 0 ? ( +
+

지출 내역이 없습니다

+

'지출 추가' 버튼을 클릭하여 첫 지출을 등록해보세요.

+
+ ) : ( +
+
제목가격 (KRW)카테고리날짜결제자
{exp.description}₩{exp.amount.toLocaleString()}{CATEGORY_LABELS[exp.category] || exp.category}{formatDate(exp.createdAt)}{exp.payerNickname}
+ + + + + + + - ))} - -
제목가격 (KRW)카테고리날짜결제자
-
- )} - - {totalPages > 1 && ( -
- - {Array.from({ length: totalPages }, (_, i) => { - const pageNum = i + 1; - const currentPage = page + 1; - if (pageNum === 1 || pageNum === totalPages || (pageNum >= currentPage - 1 && pageNum <= currentPage + 1)) { - return ; - } - if (pageNum === currentPage - 2 && currentPage > 3) return ...; - if (pageNum === currentPage + 2 && currentPage < totalPages - 2) return ...; - return null; - })} - -
- )} + + + {expenses.map(exp => ( + openDetail(exp.id)} + style={{cursor: 'pointer'}}> + {exp.description} + ₩{exp.amount.toLocaleString()} + {CATEGORY_LABELS[exp.category] + || exp.category} + {formatDate(exp.createdAt)} + {exp.payerNickname} + + ))} + + +
+ )} - {showAddDialog && setShowAddDialog(false)} onSuccess={handleAddSuccess} />} - {selectedExpenseId && } + {totalPages > 1 && ( +
+ + {Array.from({length: totalPages}, (_, i) => { + const pageNum = i + 1; + const currentPage = page + 1; + if (pageNum === 1 || pageNum === totalPages || (pageNum + >= currentPage - 1 && pageNum <= currentPage + 1)) { + return ; + } + if (pageNum === currentPage - 2 && currentPage + > 3) { + return ...; + } + if (pageNum === currentPage + 2 && currentPage < totalPages + - 2) { + return ...; + } + return null; + })} + +
+ )} + {totalPages > 1 && ( +
+ + {Array.from({length: totalPages}, (_, i) => { + const pageNum = i + 1; + const currentPage = page + 1; + if (pageNum === 1 || pageNum === totalPages || (pageNum + >= currentPage - 1 && pageNum <= currentPage + 1)) { + return ; + } + if (pageNum === currentPage - 2 && currentPage + > 3) { + return ...; + } + if (pageNum === currentPage + 2 && currentPage < totalPages + - 2) { + return ...; + } + return null; + })} + +
+ )} + + {showAddDialog && setShowAddDialog(false)} + onSuccess={handleAddSuccess}/>} + {selectedExpenseId && } +
-
); } \ No newline at end of file diff --git a/Frontend/luckeyseven/src/pages/Home.jsx b/Frontend/luckeyseven/src/pages/Home.jsx index dda2685e..3ff7e986 100644 --- a/Frontend/luckeyseven/src/pages/Home.jsx +++ b/Frontend/luckeyseven/src/pages/Home.jsx @@ -38,7 +38,7 @@ export default function Home() { const result = await logout(); if (result.success) { // 서버 로그아웃 성공 또는 실패해도 클라이언트 측 로그아웃은 완료 - navigate("/login"); + navigate("/setup-team"); } else { console.warn("서버 로그아웃 실패, 클라이언트 측 로그아웃은 완료됨"); // 클라이언트 측은 로그아웃되었으므로 로그인 페이지로 이동 @@ -102,7 +102,7 @@ export default function Home() { -
diff --git a/Frontend/luckeyseven/src/pages/Login.jsx b/Frontend/luckeyseven/src/pages/Login.jsx deleted file mode 100644 index abab0a4d..00000000 --- a/Frontend/luckeyseven/src/pages/Login.jsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useState, useEffect } from "react" -import { Link, useNavigate } from "react-router-dom" -import "../styles/auth.css" -import { login, getCurrentUser } from "../service/AuthService" - -export default function Login() { - const navigate = useNavigate(); - const[loginEmail,setLoginEmail] = useState(""); - const[loginPassword,setLoginPassword] = useState(""); - const[error, setError] = useState(""); - - // 이미 로그인된 사용자는 홈 페이지로 리다이렉트 - useEffect(() => { - const checkLoggedIn = () => { - const user = getCurrentUser(); - if (user) { - console.log("이미 로그인됨, 홈으로 이동", user); - navigate('/team-setup', { replace: true }); - } - }; - - checkLoggedIn(); - }, [navigate]); - - const changeLoginEmail = (e) => { - setLoginEmail(e.target.value); - }; - - const changeLoginPassword = (e) => { - setLoginPassword(e.target.value) - }; - - const handleLogin = async(e) => { - e.preventDefault(); - setError(""); - - try { - const req = { - email: loginEmail, - password: loginPassword - }; - console.log("로그인 요청:", req); - - const response = await login(req); - console.log("로그인 응답:", response); - - // 로그인 성공 처리 - alert("로그인 성공!"); - - // 로그인 후 홈으로 이동 (200ms 지연) - setTimeout(() => { - navigate('/team-setup', { replace: true }); - }, 200); - } catch(err) { - console.error("로그인 오류:", err); - setError(err.response?.data?.message || '로그인에 실패했습니다.'); - } - }; - - return ( -
-
-
-

로그인

-
-
- {error &&
{error}
} -
- - -
-
- - -
- -
- - 회원가입 - -
- - -
-
-
- ) -} \ No newline at end of file diff --git a/Frontend/luckeyseven/src/pages/Login/Login.jsx b/Frontend/luckeyseven/src/pages/Login/Login.jsx index aa502ad6..92220103 100644 --- a/Frontend/luckeyseven/src/pages/Login/Login.jsx +++ b/Frontend/luckeyseven/src/pages/Login/Login.jsx @@ -15,7 +15,7 @@ export default function Login() { const user = getCurrentUser(); if (user) { console.log("이미 로그인됨, 홈으로 이동", user); - navigate('/', { replace: true }); + navigate('/team-setup', { replace: true }); } }; @@ -49,7 +49,7 @@ export default function Login() { // 로그인 후 홈으로 이동 (200ms 지연) setTimeout(() => { - navigate('/', { replace: true }); + navigate('/team-setup', { replace: true }); }, 200); } catch(err) { console.error("로그인 오류:", err); diff --git a/Frontend/luckeyseven/src/pages/Settlement/HomePage.jsx b/Frontend/luckeyseven/src/pages/Settlement/HomePage.jsx index 8cf1edaa..1ae3ebaf 100644 --- a/Frontend/luckeyseven/src/pages/Settlement/HomePage.jsx +++ b/Frontend/luckeyseven/src/pages/Settlement/HomePage.jsx @@ -1,59 +1,63 @@ -import { Link } from "react-router-dom" +import {Link} from "react-router-dom" +import Header from "../../components/Header"; export function HomePage() { return ( -
-
-

여행 정산 관리

-

여행 경비를 쉽고 간편하게 정산하세요.

-
- -
-
-
-

정산 내역 관리

-

여행 중 발생한 정산 내역을 관리합니다.

-
-
-

결제자, 정산자, 지출 항목 등을 기록하고 정산 상태를 추적할 수 있습니다.

+
+
+
+
+

여행 정산 관리

+

여행 경비를 쉽고 간편하게 정산하세요.

-
- - - -
-
-
-
-

새 정산 생성

-

새로운 정산 내역을 생성합니다.

-
-
-

여행 중 발생한 지출에 대한 정산 내역을 생성하고 관리할 수 있습니다.

-
-
- - - -
-
+
+
+
+

정산 내역 관리

+

여행 중 발생한 정산 내역을 관리합니다.

+
+
+

결제자, 정산자, 지출 항목 등을 기록하고 정산 상태를 추적할 수 있습니다.

+
+
+ + + +
+
-
-
-

정산 통계

-

정산 내역에 대한 통계를 확인합니다.

-
-
-

팀원별 지출 및 정산 금액, 정산 완료 상태 등의 통계를 확인할 수 있습니다.

-
-
- +
+
+

새 정산 생성

+

새로운 정산 내역을 생성합니다.

+
+
+

여행 중 발생한 지출에 대한 정산 내역을 생성하고 관리할 수 있습니다.

+
+
+ + + +
+
+ +
+
+

정산 통계

+

정산 내역에 대한 통계를 확인합니다.

+
+
+

팀원별 지출 및 정산 금액, 정산 완료 상태 등의 통계를 확인할 수 있습니다.

+
+
+ +
+
-
) } diff --git a/Frontend/luckeyseven/src/pages/Settlement/SettlementDetailPage.jsx b/Frontend/luckeyseven/src/pages/Settlement/SettlementDetailPage.jsx index c6010c3c..8104cbec 100644 --- a/Frontend/luckeyseven/src/pages/Settlement/SettlementDetailPage.jsx +++ b/Frontend/luckeyseven/src/pages/Settlement/SettlementDetailPage.jsx @@ -1,14 +1,14 @@ "use client" -import { useState, useEffect } from "react" -import { useParams } from "react-router-dom" -import { SettlementDetail } from "../../components/settlement/settlement-detail" -import { getSettlementById } from "../../service/settlementService" -import { useToast } from "../../context/ToastContext" +import {useEffect, useState} from "react" +import {useParams} from "react-router-dom" +import {SettlementDetail} from "../../components/settlement/settlement-detail" +import {getSettlementById} from "../../service/settlementService" +import {useToast} from "../../context/ToastContext" export function SettlementDetailPage() { - const { settlementId } = useParams() - const { addToast } = useToast() + const {settlementId} = useParams() + const {addToast} = useToast() const [settlement, setSettlement] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) @@ -37,29 +37,29 @@ export function SettlementDetailPage() { if (isLoading) { return ( -
-
-

로딩 중...

+
+
+

로딩 중...

+
-
) } if (error || !settlement) { return ( -
-
-

오류가 발생했습니다.

-

{error || "정산 내역을 찾을 수 없습니다."}

+
+
+

오류가 발생했습니다.

+

{error || "정산 내역을 찾을 수 없습니다."}

+
-
) } return ( -
-

정산 상세 내역

- -
+
+

정산 상세 내역

+ +
) -} +} \ No newline at end of file diff --git a/Frontend/luckeyseven/src/pages/Settlement/SettlementEditPage.jsx b/Frontend/luckeyseven/src/pages/Settlement/SettlementEditPage.jsx index 8aad020d..7753ab40 100644 --- a/Frontend/luckeyseven/src/pages/Settlement/SettlementEditPage.jsx +++ b/Frontend/luckeyseven/src/pages/Settlement/SettlementEditPage.jsx @@ -6,9 +6,13 @@ import {SettlementForm} from "../../components/settlement/settlement-form" import {getSettlementById, getUsers} from "../../service/settlementService" import {useToast} from "../../context/ToastContext" import {getAllExpense} from "../../service/ExpenseService"; +import {useRecoilValue} from "recoil"; +import {currentTeamIdState} from "../../recoil/atoms/teamAtoms"; export function SettlementEditPage() { - const {teamId} = useParams() + const recoilTeamId = useRecoilValue(currentTeamIdState) + const paramTeamId = useParams().teamId + const teamId = recoilTeamId || paramTeamId const {settlementId} = useParams() const {addToast} = useToast() const [settlement, setSettlement] = useState(null) diff --git a/Frontend/luckeyseven/src/pages/Settlement/SettlementNewPage.jsx b/Frontend/luckeyseven/src/pages/Settlement/SettlementNewPage.jsx index 89ae9369..b8f4513b 100644 --- a/Frontend/luckeyseven/src/pages/Settlement/SettlementNewPage.jsx +++ b/Frontend/luckeyseven/src/pages/Settlement/SettlementNewPage.jsx @@ -6,9 +6,13 @@ import {getUsers} from "../../service/settlementService" import {useToast} from "../../context/ToastContext" import {getAllExpense} from "../../service/ExpenseService"; import {useParams} from "react-router-dom"; +import {useRecoilValue} from "recoil"; +import {currentTeamIdState} from "../../recoil/atoms/teamAtoms"; export function SettlementNewPage() { - const {teamId} = useParams() + const recoilTeamId = useRecoilValue(currentTeamIdState) + const paramTeamId = useParams().teamId + const teamId = recoilTeamId || paramTeamId const {addToast} = useToast() const [users, setUsers] = useState([]) const [expenses, setExpenses] = useState([]) diff --git a/Frontend/luckeyseven/src/pages/Settlement/TeamSettlementsPage.jsx b/Frontend/luckeyseven/src/pages/Settlement/TeamSettlementsPage.jsx index ddfec78b..bbf51970 100644 --- a/Frontend/luckeyseven/src/pages/Settlement/TeamSettlementsPage.jsx +++ b/Frontend/luckeyseven/src/pages/Settlement/TeamSettlementsPage.jsx @@ -1,15 +1,19 @@ "use client" import {useEffect, useState} from "react" -import {Link, useLocation, useNavigate, useParams} from "react-router-dom" +import {useLocation, useNavigate, useParams} from "react-router-dom" import {SettlementList} from "../../components/settlement/settlement-list" import {SettlementFilter} from "../../components/settlement/settlement-filter" import {getListSettlements, getUsers} from "../../service/settlementService" import {useToast} from "../../context/ToastContext" import {getAllExpense} from "../../service/ExpenseService"; +import {useRecoilValue} from "recoil"; +import {currentTeamIdState} from "../../recoil/atoms/teamAtoms"; export function TeamSettlementsPage() { - const {teamId} = useParams() + const recoilTeamId = useRecoilValue(currentTeamIdState) + const paramTeamId = useParams().teamId + const teamId = recoilTeamId || paramTeamId const location = useLocation() const navigate = useNavigate() const {addToast} = useToast() @@ -57,20 +61,18 @@ export function TeamSettlementsPage() { const fetchData = async () => { try { setIsLoading(true) - - // 페이징 처리된 데이터 로드 - const settlements = await getListSettlements(teamId, page, size, sort, - filters) - const users = await getUsers(teamId) - const expenses = await getAllExpense(teamId) - - setSettlements(settlements.content) - setUsers(users.data) - setExpenses(expenses) + const settlementResponse = await getListSettlements(teamId, page, size, + sort, filters) + setSettlements(settlementResponse) + const usersResponse = await getUsers(teamId) + setUsers(usersResponse) + const expensesResponse = await getAllExpense(teamId) + setExpenses(expensesResponse) // 페이징 메타데이터 설정 - setTotalPages(settlements.totalPages) - setTotalElements(settlements.totalElements) + setTotalPages(settlementResponse.totalPages) + setTotalElements(settlementResponse.totalElements) + console.info(settlements) } catch (error) { console.error("팀 정산 내역 조회 오류:", error) setError(error.message) @@ -112,9 +114,6 @@ export function TeamSettlementsPage() {

팀 정산 내역

- - -
@@ -122,7 +121,7 @@ export function TeamSettlementsPage() { initialFilters={filters} teamId={teamId}/>
- + {/* 페이지네이션 컴포넌트 */}
diff --git a/Frontend/luckeyseven/src/pages/SettlementPage.js b/Frontend/luckeyseven/src/pages/SettlementPage.js index 2e54343a..0e726320 100644 --- a/Frontend/luckeyseven/src/pages/SettlementPage.js +++ b/Frontend/luckeyseven/src/pages/SettlementPage.js @@ -1,21 +1,21 @@ -import React from 'react'; -import { useParams } from 'react-router-dom'; -import Header from '../components/Header'; // Assuming Header is a common component -import styles from '../styles/App.module.css'; // Or a more specific style file - -function SettlementPage() { - const { teamId } = useParams(); - - return ( -
-
-
-

Settlement Page

-

Content for team ID: {teamId} will be displayed here.

- {/* Placeholder for settlement content */} -
-
- ); -} - -export default SettlementPage; +// import React from 'react'; +// import { useParams } from 'react-router-dom'; +// import Header from '../components/Header'; // Assuming Header is a common component +// import styles from '../styles/App.module.css'; // Or a more specific style file +// +// function SettlementPage() { +// const { teamId } = useParams(); +// +// return ( +//
+//
+//
+//

Settlement Page

+//

Content for team ID: {teamId} will be displayed here.

+// {/* Placeholder for settlement content */} +//
+//
+// ); +// } +// +// export default SettlementPage; diff --git a/Frontend/luckeyseven/src/pages/TeamDashBoard.js b/Frontend/luckeyseven/src/pages/TeamDashBoard.js index b0f2867c..e0104dae 100644 --- a/Frontend/luckeyseven/src/pages/TeamDashBoard.js +++ b/Frontend/luckeyseven/src/pages/TeamDashBoard.js @@ -1,31 +1,120 @@ import React, { useState, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { currentTeamIdState } from '../recoil/atoms/teamAtoms'; -import { getTeamDashboard, getTeamMembers } from '../service/ApiService'; -import styles from '../styles/App.module.css'; // 수정됨 +import { getTeamDashboard, getTeamMembers } from '../service/TeamService'; +import styles from '../styles/App.module.css'; import Header from '../components/Header'; import PageHeaderControls from '../components/PageHeaderControls'; import Tabs from '../components/Tabs'; import OverviewTabContent from '../components/OverviewTabContent'; import MembersTabContent from '../components/MembersTabContent'; - +import SetBudgetDialog from '../pages/BudgetPage/components/set-budget-dialog'; +import EditBudgetDialog from '../pages/BudgetPage/components/edit-budget-dialog'; +import AddBudgetDialog from '../pages/BudgetPage/components/add-budget-dialog'; const DoughnutChartPlaceholder = () => ( -
- Chart -
+
+ Chart +
); function TeamDashBoard() { const [activeTab, setActiveTab] = useState('Overview'); const teamId = useRecoilValue(currentTeamIdState); const [dashboardData, setDashboardData] = useState(null); - const [pageHeaderData, setPageHeaderData] = useState({teamName: ''}); + // Added state for dialog control + const [dialogType, setDialogType] = useState(null); // 'set', 'edit', 'add', or null + // 다이얼로그 인스턴스를 구분하기 위한 키 생성 상태 추가 + const [dialogKey, setDialogKey] = useState(Date.now()); + const [pageHeaderData, setPageHeaderData] = useState({ + teamName: '', + openDialog: (type) => { + // 다이얼로그 타입을 설정하고 새 키 생성 + setDialogType(type); + setDialogKey(Date.now()); + } + }); const [membersData, setMembersData] = useState({ teamCode: '', teamPassword: '', - members: [] + members: [] }); + const [budgetData, setBudgetData] = useState(null); + const [budgetInitialized, setBudgetInitialized] = useState(false); + + // Close dialog handler + const handleCloseDialog = () => { + setDialogType(null); + }; + + // Open dialog handler with new instance key + const handleOpenDialog = (type) => { + setDialogType(type); + setDialogKey(Date.now()); // 새로운 키 생성하여 컴포넌트 재생성 보장 + }; + + // Function to handle budget updates from dialogs + const handleBudgetUpdate = (updatedBudget) => { + setBudgetData(updatedBudget); + setBudgetInitialized(true); + + if (dashboardData) { + setDashboardData({ + ...dashboardData, + budget: { + ...dashboardData.budget, + totalAmount: updatedBudget?.totalAmount || 0, + balance: updatedBudget?.balance || 0, + foreignBalance: updatedBudget?.foreignBalance || 0, + foreignCurrency: updatedBudget?.foreignCurrency || 'KRW', + avgExchangeRate: updatedBudget?.avgExchangeRate || 0 + } + }); + } + + setDialogType(null); + }; + + // 예산 삭제 처리 함수 - 수정됨 + const handleBudgetDelete = async () => { + // 예산 데이터 초기화 + setBudgetData(null); + setBudgetInitialized(false); + + // 대시보드 정보에서 예산 정보를 0으로 리셋 + if (dashboardData) { + setDashboardData({ + ...dashboardData, + budget: { + totalAmount: 0, + balance: 0, + foreignBalance: 0, + foreignCurrency: 'KRW', + avgExchangeRate: 0 + } + }); + } + + // 모든 관련 대화상자 닫기 + setDialogType(null); + + // 필요하다면 대시보드 데이터를 새로 불러옴 + try { + const refreshedData = await getTeamDashboard(teamId); + setDashboardData({ + ...refreshedData, + budget: { + totalAmount: 0, + balance: 0, + foreignBalance: 0, + foreignCurrency: 'KRW', + avgExchangeRate: 0 + } + }); + } catch (error) { + console.error("대시보드 데이터 갱신 실패:", error); + } + }; useEffect(() => { const fetchData = async () => { @@ -33,47 +122,107 @@ function TeamDashBoard() { try { const overviewData = await getTeamDashboard(teamId); console.log("Overview Data:", overviewData); - setDashboardData(overviewData); - const { teamName, teamCode, teamPassword} = overviewData; - + // Make sure budget values are never null by using nullish coalescing + setDashboardData({ + ...overviewData, + budget: { + totalAmount: budgetData?.totalAmount ?? overviewData?.totalAmount ?? 0, + balance: budgetData?.balance ?? overviewData?.balance ?? 0, + foreignBalance: budgetData?.foreignBalance ?? overviewData?.foreignBalance ?? 0, + foreignCurrency: budgetData?.foreignCurrency ?? overviewData?.foreignCurrency ?? 'KRW', + avgExchangeRate: budgetData?.avgExchangeRate ?? overviewData?.avgExchangeRate ?? 0, + } + }); + + const { teamName, teamCode, teamPassword } = overviewData || {}; + const teamMembers = await getTeamMembers(teamId); - + console.log("Team Members Data:", teamMembers); setMembersData({ - teamCode, - teamPassword, - members: teamMembers + teamCode: teamCode || '', + teamPassword: teamPassword || '', + members: teamMembers || [] }); - console.log("teamName:", teamName); - setPageHeaderData({teamName}); - } catch (error) { + console.log("teamName:", teamName); + setPageHeaderData({ + teamName: teamName || `Team ${teamId}`, + openDialog: handleOpenDialog + }); + } catch (error) { console.error("Error fetching team data:", error); - // Handle error appropriately, e.g., show a notification + // 에러가 409 Conflict인 경우, 아직 예산이 설정되지 않은 것으로 간주 + if (error.response && error.response.status === 409) { + // 기본 대시보드 데이터 설정 + const basicDashboardData = { + team_id: teamId, + teamName: membersData.teamName || `Team ${teamId}`, + totalAmount: 0, + balance: 0, + foreignBalance: 0, + foreignCurrency: 'KRW', + expenseList: [], + avgExchangeRate: 0 + }; + setDashboardData(basicDashboardData); + } } } }; fetchData(); - }, [teamId]); + }, [teamId, budgetData, budgetInitialized]); return (
- + - {activeTab === 'Overview' && } + {activeTab === 'Overview' && + + } {activeTab === 'Members' && ( - + + )} + + {/* Render dialogs based on dialog type with consistent key */} + {dialogType === 'set' && ( + + )} + {dialogType === 'edit' && ( + + )} + {dialogType === 'add' && ( + )}
); } -export default TeamDashBoard; +export default TeamDashBoard; \ No newline at end of file diff --git a/Frontend/luckeyseven/src/pages/TeamSetup.jsx b/Frontend/luckeyseven/src/pages/TeamSetup.jsx index ec583e89..99cc9568 100644 --- a/Frontend/luckeyseven/src/pages/TeamSetup.jsx +++ b/Frontend/luckeyseven/src/pages/TeamSetup.jsx @@ -1,11 +1,11 @@ import React, { useState, useEffect } from 'react'; import styles from '../styles/TeamSetup.module.css'; import Header from '../components/Header'; -import { createTeam, joinTeam } from '../service/ApiService'; +import { createTeam, joinTeam } from '../service/TeamService'; import { useNavigate } from 'react-router-dom'; import { useSetRecoilState } from 'recoil'; import { currentTeamIdState } from '../recoil/atoms/teamAtoms'; -import { getMyTeams } from '../service/ApiService'; // 팀 목록 불러오기 +import { getMyTeams } from '../service/TeamService'; // 팀 목록 불러오기 diff --git a/Frontend/luckeyseven/src/service/ApiService.js b/Frontend/luckeyseven/src/service/ApiService.js index a8f48faa..07ad0d1a 100644 --- a/Frontend/luckeyseven/src/service/ApiService.js +++ b/Frontend/luckeyseven/src/service/ApiService.js @@ -107,15 +107,41 @@ export async function postRefreshToken() { try { const response = await privateApi.post( '/api/refresh', - {}, + {}, // 빈 body { - headers: { - Authorization: `Bearer ${token}`, - }, - withCredentials: true - }, + withCredentials: true, // 쿠키가 함께 전송되도록 명시적으로 설정 (매우 중요) + + // 토큰이 있을 경우에만 헤더에 포함 + ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}) + } ); - console.log('postRefreshToken response:', response); + + console.log('토큰 갱신 응답 상태:', response.status); + console.log('토큰 갱신 응답 헤더:', JSON.stringify(response.headers)); + + // 응답에서 새 토큰 확인 (헤더에서 가져오기) + const authHeader = response.headers?.authorization || response.headers?.['authorization']; + if (authHeader && authHeader.startsWith('Bearer ')) { + const newToken = authHeader.substring(7); + console.log('응답 헤더에서 새 토큰 발견:', newToken.substring(0, 10) + "..."); + + // response.data에 accessToken 추가 + if (!response.data) { + response.data = {}; + } + response.data.accessToken = newToken; + console.log('응답 데이터에 accessToken 추가됨:', newToken.substring(0, 10) + "..."); + + // 새 토큰으로 헤더 즉시 업데이트 + axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`; + console.log('axios 기본 헤더에 새 토큰 설정됨'); + + // localStorage에 accessToken 저장 + localStorage.setItem('accessToken', newToken); + console.log('localStorage에 accessToken 저장됨'); + } else { + console.warn('응답 헤더에서 Authorization 토큰을 찾을 수 없습니다.'); + } return response; } catch (error) { console.error('postRefreshToken error:', error); @@ -123,70 +149,3 @@ export async function postRefreshToken() { throw error; } } - -// Team API -export const getTeamDashboard = async (teamId) => { - try { - const response = await privateApi.get(`/api/teams/${teamId}/dashboard`); - return response.data; - } catch (error) { - console.error('Error fetching team dashboard:', error); - throw error; - } -}; - -export const getTeamMembers = async (teamId) => { - try { - const response = await privateApi.get(`/api/teams/${teamId}/members`); - return response.data; - } catch (error) { - console.error('Error fetching team members:', error); - throw error; - } -}; - -export const createTeam = async (name, teamPassword) => { - try { - const response = await privateApi.post('/api/teams', { - name, - teamPassword, - }); - return response.data; - } catch (error) { - console.error('Error creating team:', error); - throw error; - } -}; - -export const joinTeam = async (teamCode, teamPassword) => { - try { - const response = await privateApi.post('/api/teams/members', { - teamCode, - teamPassword, - }); - return response.data; - } catch (error) { - console.error('Error joining team:', error); - throw error; - } -}; - -export async function getMyTeams() { - try { - const response = await privateApi.get('/api/teams/myTeams'); - return response.data; - } catch (error) { - console.error('Error fetching my teams:', error); - throw error; - } -} - -export const deleteTeam = async (teamId) => { - try { - const response = await privateApi.delete(`/api/teams/${teamId}`); - return response.data; - } catch (error) { - console.error('Error deleting team:', error); - throw error; - } -}; diff --git a/Frontend/luckeyseven/src/service/AuthService.js b/Frontend/luckeyseven/src/service/AuthService.js index 022b0129..aa927421 100644 --- a/Frontend/luckeyseven/src/service/AuthService.js +++ b/Frontend/luckeyseven/src/service/AuthService.js @@ -4,16 +4,26 @@ import { postRefreshToken } from "./ApiService"; // 사용자 정보 저장 let currentUser = null; -// 로컬 스토리지에서 사용자 정보 불러오기 + +// 로컬 스토리지에서 사용자 정보와 토큰 복원 try { + // 사용자 정보 불러오기 const storedUser = localStorage.getItem('currentUser'); if (storedUser) { currentUser = JSON.parse(storedUser); - // accessToken은 localStorage에 저장하지 않고 헤더에만 설정 + } + + // accessToken 불러오기 및 헤더에 설정 + const accessToken = localStorage.getItem('accessToken'); + if (accessToken) { + console.log("로컬 스토리지에서 accessToken 복원:", accessToken.substring(0, 10) + "..."); + axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`; + console.log("로컬 스토리지의 accessToken으로 Authorization 헤더 설정됨"); } } catch (error) { console.error("로컬 스토리지 데이터 로드 오류:", error); } + export const join = async (req) =>{ try{ const resp = await publicApi.post("/api/users/register", req); @@ -23,64 +33,90 @@ export const join = async (req) =>{ throw err; } } + export const login = async(req) => { try { console.log("로그인 요청 데이터:", req); + // withCredentials를 명시적으로 설정하여 쿠키를 받을 수 있도록 함 const resp = await privateApi.post("/api/users/login", req, { withCredentials: true }); - console.log("로그인 응답 데이터:", resp.data); + + console.log("[1]로그인 응답 데이터:", resp.data); console.log("로그인 응답 헤더:", resp.headers); + + // 쿠키 확인 (HttpOnly 쿠키는 보이지 않음) console.log("로그인 후 쿠키 (HttpOnly 제외):", document.cookie); + // 토큰 설정 - 응답 헤더에서 가져옴 let authHeader = null; + // 다양한 방식으로 헤더 접근 시도 if (resp.headers.get) { authHeader = resp.headers.get('authorization'); } - + if (!authHeader && resp.headers.authorization) { authHeader = resp.headers.authorization; } + if (!authHeader && resp.headers['authorization']) { authHeader = resp.headers['authorization']; } + if (authHeader && authHeader.startsWith('Bearer ')) { const accessToken = authHeader.substring(7); console.log("액세스 토큰 발견:", accessToken.substring(0, 10) + "..."); + // Authorization 헤더 설정 axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`; console.log("Authorization 헤더 설정 완료"); - // localStorage에는 저장하지 않음 + + // 헤더 설정 확인 로그 추가 + console.log("설정된 Authorization 헤더:", axios.defaults.headers.common['Authorization']); + + // localStorage에 accessToken 저장 (이전에는 저장하지 않았지만, 이제 저장함) + localStorage.setItem('accessToken', accessToken); + console.log("accessToken을 localStorage에 저장했습니다."); } else { console.log("응답 헤더에서 액세스 토큰을 찾을 수 없습니다."); } + // 사용자 정보 저장 const email = resp.data.email || req.email; const nickname = resp.data.nickname || req.email.split('@')[0]; + currentUser = { email, nickname }; console.log("저장된 사용자 정보:", currentUser); - // 로컬 스토리지에 사용자 정보 저장 + + // 로컬 스토리지에 사용자 정보 저장 (토큰은 저장하지 않음) localStorage.setItem('currentUser', JSON.stringify(currentUser)); - + // refreshToken은 HttpOnly 쿠키로 설정되어 있어 접근 불가능 console.log("refreshToken은 HttpOnly 쿠키로 설정되어 있어 자바스크립트에서 접근할 수 없습니다."); console.log("로그아웃 시 쿠키가 자동으로 전송됩니다."); - + + // 인증 상태 최종 확인 + console.log("=== 로그인 후 인증 상태 확인 ==="); + console.log("메모리의 currentUser:", currentUser); + console.log("Authorization 헤더:", axios.defaults.headers.common['Authorization']); + console.log("privateApi 기본 헤더:", privateApi.defaults.headers); + return resp.data; } catch(err) { console.error("로그인 API 오류:", err); throw err; } } + + export const getCurrentUser = () => { console.log("getCurrentUser 호출됨"); console.log("현재 메모리의 currentUser:", currentUser); console.log("현재 Authorization 헤더:", axios.defaults.headers.common['Authorization'] || "없음"); - - + // 현재 메모리에 사용자 정보가 없는 경우 로컬 스토리지에서 확인 if (!currentUser) { console.log("메모리에 currentUser가 없음, localStorage 확인 중..."); @@ -97,10 +133,12 @@ export const getCurrentUser = () => { console.error("로컬 스토리지 데이터 로드 오류:", error); } } - + console.log("최종 반환되는 currentUser:", currentUser); return currentUser; } + + export const checkEmailDuplicate = async(email) => { try{ const resp = await publicApi.post("api/users/checkEmail",{params : {email : email}}); @@ -111,10 +149,16 @@ export const checkEmailDuplicate = async(email) => { console.log(err); } } + export const logout = async() => { try { console.log("logout 진입"); - + + // 현재 인증 상태 확인 + console.log("로그아웃 전 인증 상태:"); + console.log("- currentUser:", currentUser); + console.log("- Authorization 헤더:", axios.defaults.headers.common['Authorization'] || "없음"); + // HttpOnly 쿠키는 자바스크립트에서 접근할 수 없지만, // withCredentials: true 설정으로 쿠키가 자동으로 요청에 포함됨 try { @@ -125,7 +169,6 @@ export const logout = async() => { // - Access-Control-Allow-Origin: http://localhost:3000 (프론트엔드 도메인) // - Access-Control-Allow-Credentials: true }); - console.log("로그아웃 성공:", response.data); } catch (apiError) { @@ -137,27 +180,41 @@ export const logout = async() => { console.error("로그아웃 실패 - 쿠키가 전송되지 않았을 가능성이 있습니다."); console.error("백엔드 CORS 설정과 쿠키 설정을 확인하세요."); } - + // API 오류가 발생해도 클라이언트 상태 정리는 진행 } + // 항상 실행되는 클라이언트 상태 정리 // 현재 사용자 정보 초기화 currentUser = null; + // 로컬 스토리지에서 사용자 정보 제거 localStorage.removeItem('currentUser'); + + // localStorage에서 accessToken 제거 + localStorage.removeItem('accessToken'); + console.log("localStorage에서 accessToken 제거됨"); + // Authorization 헤더 제거 delete axios.defaults.headers.common['Authorization']; + + console.log("로그아웃 후 인증 상태:"); + console.log("- currentUser:", currentUser); + console.log("- Authorization 헤더:", axios.defaults.headers.common['Authorization'] || "없음"); + console.log("로그아웃 완료: 사용자 정보 및 토큰 제거됨"); + // 서버 로그아웃에 실패해도 클라이언트 로그아웃은 성공으로 처리 return { success: true }; } catch (err) { console.error("로그아웃 처리 중 오류:", err); - + // 오류가 발생해도 클라이언트 상태 정리 currentUser = null; localStorage.removeItem('currentUser'); + localStorage.removeItem('accessToken'); // accessToken도 제거 delete axios.defaults.headers.common['Authorization']; - + return { success: false, error: err.message }; } } @@ -166,20 +223,58 @@ export const logout = async() => { export const refreshAccessToken = async () => { try { console.log("토큰 갱신 요청 시작"); + console.log("현재 인증 상태:"); + console.log("- currentUser:", currentUser); + console.log("- Authorization 헤더:", axios.defaults.headers.common['Authorization']); + + // 쿠키에 refreshToken이 있는지 확인할 수 없음 (HttpOnly) + console.log("refreshToken은 HttpOnly 쿠키로 설정되어 있어 확인 불가능"); + + // refreshToken을 사용하여 accessToken 갱신 요청 const response = await postRefreshToken(); - console.log("토큰 갱신 성공:", response.data); - - // 만약 새 액세스 토큰이 반환되면 헤더에 설정 + console.log("토큰 갱신 응답:", response.data); + + // 새 액세스 토큰 설정 const newAccessToken = response.data.accessToken; if (newAccessToken) { console.log("새 액세스 토큰 설정:", newAccessToken.substring(0, 10) + "..."); axios.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`; + + // 갱신 후 인증 상태 확인 + console.log("갱신 후 Authorization 헤더:", axios.defaults.headers.common['Authorization']); + + return { + success: true, + data: response.data, + message: "토큰이 성공적으로 갱신되었습니다." + }; + } else { + console.warn("응답에 새 액세스 토큰이 포함되어 있지 않습니다:", response.data); + return { + success: false, + error: "응답에 액세스 토큰이 없습니다.", + data: response.data + }; } - - return { success: true, data: response.data }; } catch (error) { console.error("토큰 갱신 실패:", error); - return { success: false, error: error.message }; + + if (error.response) { + console.error("서버 응답:", error.response.status, error.response.data); + } + + // 에러 발생 시 인증 상태 초기화 여부 결정 + if (error.response && error.response.status === 401) { + console.warn("인증 만료: 사용자 정보 초기화"); + currentUser = null; + localStorage.removeItem('currentUser'); + delete axios.defaults.headers.common['Authorization']; + } + + return { + success: false, + error: error.message, + status: error.response?.status + }; } - }; diff --git a/Frontend/luckeyseven/src/service/TeamService.js b/Frontend/luckeyseven/src/service/TeamService.js new file mode 100644 index 00000000..8bfc051e --- /dev/null +++ b/Frontend/luckeyseven/src/service/TeamService.js @@ -0,0 +1,68 @@ +import { privateApi } from './ApiService'; + +// Team API +export const getTeamDashboard = async (teamId) => { + try { + const response = await privateApi.get(`/api/teams/${teamId}/dashboard`); + return response.data; + } catch (error) { + console.error('Error fetching team dashboard:', error); + throw error; + } + }; + + export const getTeamMembers = async (teamId) => { + try { + const response = await privateApi.get(`/api/teams/${teamId}/members`); + return response.data; + } catch (error) { + console.error('Error fetching team members:', error); + throw error; + } + }; + + export const createTeam = async (name, teamPassword) => { + try { + const response = await privateApi.post('/api/teams', { + name, + teamPassword, + }); + return response.data; + } catch (error) { + console.error('Error creating team:', error); + throw error; + } + }; + + export const joinTeam = async (teamCode, teamPassword) => { + try { + const response = await privateApi.post('/api/teams/members', { + teamCode, + teamPassword, + }); + return response.data; + } catch (error) { + console.error('Error joining team:', error); + throw error; + } + }; + + export async function getMyTeams() { + try { + const response = await privateApi.get('/api/teams/myTeams'); + return response.data; + } catch (error) { + console.error('Error fetching my teams:', error); + throw error; + } + } + + export const deleteTeam = async (teamId) => { + try { + const response = await privateApi.delete(`/api/teams/${teamId}`); + return response.data; + } catch (error) { + console.error('Error deleting team:', error); + throw error; + } + }; \ No newline at end of file diff --git a/Frontend/luckeyseven/src/service/settlementService.js b/Frontend/luckeyseven/src/service/settlementService.js index f18cff82..36f7f6c0 100644 --- a/Frontend/luckeyseven/src/service/settlementService.js +++ b/Frontend/luckeyseven/src/service/settlementService.js @@ -1,6 +1,5 @@ import {privateApi} from "./ApiService"; -// 모든 정산 내역 조회 export const getListSettlements = async (teamId, page = 0, size = 10, sort = 'createdAt,DESC', filters = {}) => { try { @@ -48,12 +47,12 @@ export const updateSettlement = async (id, settlementData, try { // settledOnly가 true인 경우 정산 완료 처리만 수행 if (settledOnly) { - const response = await privateApi.patch(`/api/settlements/${id}`, + const response = await privateApi.patch(`/api/settlements/${id}`, {}, {params: {settledOnly: true}}); return response.data; } else { // 전체 정산 정보 업데이트 - const response = await privateApi.put(`/api/settlements/${id}`, + const response = await privateApi.patch(`/api/settlements/${id}`, settlementData); return response.data; } diff --git a/Frontend/luckeyseven/src/styles/PageHeaderControls.module.css b/Frontend/luckeyseven/src/styles/PageHeaderControls.module.css index f657c18a..1b66d37d 100644 --- a/Frontend/luckeyseven/src/styles/PageHeaderControls.module.css +++ b/Frontend/luckeyseven/src/styles/PageHeaderControls.module.css @@ -25,16 +25,30 @@ } .buttonSecondary { - margin-right: 0.625rem; + margin-left: 0.25rem; + margin-right: 0.25rem; padding: 0.625rem 0.9375rem; border: 1px solid #ccc; border-radius: 0.3125rem; - background-color: #f9f9f9; + background-color: var(--color-secondary-rgb); + cursor: pointer; + font-size: clamp(0.75rem, 2vw, 0.875rem); +} + +.buttonSecondary:hover { + margin-left: 0.25rem; + margin-right: 0.25rem; + padding: 0.625rem 0.9375rem; + border: 1px solid #ccc; + border-radius: 0.3125rem; + background-color: var(--color-secondary); cursor: pointer; font-size: clamp(0.75rem, 2vw, 0.875rem); } .buttonPrimary { + margin-left: 0.25rem; + margin-right: 0.25rem; padding: 0.625rem 0.9375rem; border: none; border-radius: 0.3125rem; @@ -43,3 +57,39 @@ cursor: pointer; font-size: clamp(0.75rem, 2vw, 0.875rem); } + +.buttonPrimary:hover { + margin-left: 0.25rem; + margin-right: 0.25rem; + padding: 0.625rem 0.9375rem; + border: none; + border-radius: 0.3125rem; + background-color: var(--color-primary-hover); + color: white; + cursor: pointer; + font-size: clamp(0.75rem, 2vw, 0.875rem); +} + +.buttonDanger { + margin-left: 0.25rem; + margin-right: 0.25rem; + padding: 0.625rem 0.9375rem; + border: none; + border-radius: 0.3125rem; + background-color: var(--color-destructive); + color: white; + cursor: pointer; + font-size: clamp(0.75rem, 2vw, 0.875rem); +} + +.buttonDanger:hover { + margin-left: 0.25rem; + margin-right: 0.25rem; + padding: 0.625rem 0.9375rem; + border: none; + border-radius: 0.3125rem; + background-color: rgba(var(--color-destructive-rgb), 0.9); + color: white; + cursor: pointer; + font-size: clamp(0.75rem, 2vw, 0.875rem); +} \ No newline at end of file