Compare commits
24 Commits
68dccc10aa
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 861fac88f7 | |||
| 8d6bff88d6 | |||
| 52f8c30b2e | |||
| 01c3590682 | |||
| 01bfc751f2 | |||
| b390777af0 | |||
| 8b33161284 | |||
| 35f6023f5a | |||
| a836333512 | |||
| 57a6e4b51e | |||
| 5c4df5a19a | |||
| ec281bf354 | |||
| 9cd5ff309f | |||
| 839901d663 | |||
| 76dab1c6a9 | |||
| df7c877319 | |||
| 46dc4fdf73 | |||
| 34d5a56a80 | |||
| 6da730f014 | |||
| 9b9fb24028 | |||
| db109dfe9a | |||
| 42cd85f8e5 | |||
| 2d0834472d | |||
| 83ad885641 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
/api-app/app/src/main/resources/application-local.yml
|
/api-app/app/src/main/resources/application-local.yml
|
||||||
|
api-app/.gradle
|
||||||
|
api-app/build
|
||||||
@@ -147,25 +147,33 @@ build/libs/ROOT.jar
|
|||||||
```
|
```
|
||||||
api-app # 메인 실행 모듈
|
api-app # 메인 실행 모듈
|
||||||
├── app
|
├── app
|
||||||
│ └── src/main/java/com/cd/detection
|
│ ├── src/main
|
||||||
│ ├── DabeeoDetectionApiApplication.java
|
│ │ ├── java/com/cd/detection
|
||||||
│ │
|
│ │ │ ├── DabeeoDetectionApiApplication.java
|
||||||
│ ├── config # 설정 파일
|
│ │ │
|
||||||
│ │
|
│ │ │ ├── config # 설정 파일
|
||||||
│ ├── domain # 도메인
|
│ │ │ ├── db
|
||||||
│ │ ├── imagery # 영상 데이터 관리
|
│ │ │ │ ├── core # core service (Repository 비즈니스 서비스)
|
||||||
│ │ ├── labeling # 라벨링 툴
|
│ │ │ │ └── repository # Repository (DB 인터페이스/구현)
|
||||||
│ │ ├── label # 라벨링 관리
|
│ │ │ ├── core
|
||||||
│ │ ├── model # 모델관리
|
│ │ │ ├── domain # 도메인
|
||||||
│ │ ├── inference # 추론관리
|
│ │ │ │ ├── imagery # 영상 데이터 관리
|
||||||
│ │ ├── system # 시스템관리
|
│ │ │ │ ├── labeling # 라벨링 툴
|
||||||
│ │ └── log # 로그관리
|
│ │ │ │ ├── label # 라벨링 관리
|
||||||
│ │
|
│ │ │ │ ├── model # 모델관리
|
||||||
│ ├── entity # 공용 JPA 엔티티 (DB 공통 사용)
|
│ │ │ │ ├── inference # 추론관리
|
||||||
│ │
|
│ │ │ │ ├── system # 시스템관리
|
||||||
│ └── repository # 공용 Repository (DB 공통 인터페이스/구현)
|
│ │ │ │ └── log # 로그관리
|
||||||
|
│ │ │ ├── entity # 공용 JPA 엔티티 (DB 공통 사용)
|
||||||
|
│ │ │
|
||||||
|
│ │ └── resources # 설정 및 리소스 파일
|
||||||
|
│ │ ├── application.yml
|
||||||
|
│ │ ├── application-dev.yml
|
||||||
|
│ │ └── application-prod.yml
|
||||||
|
│ │
|
||||||
|
│ └── build.gradle
|
||||||
│
|
│
|
||||||
├── infrastructure-db-postgres # Postgres 전용 설정/확장
|
├── infrastructure-db-postgres # Postgres 전용 설정/확장
|
||||||
│ └── build.gradle
|
│ └── build.gradle
|
||||||
│
|
│
|
||||||
├── build.gradle
|
├── build.gradle
|
||||||
@@ -206,20 +214,19 @@ DB 관련 로직은 별도의 모듈(infrastructure-db-postgres)로 분리되어
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🔄 처리 흐름
|
### 🔄 Database 교체
|
||||||
|
|
||||||
Controller → Service → Repository → DB
|
- `api-app/build.gradle`에서 DB 모듈 의존성 변경
|
||||||
|
(`infrastructure-db-postgres` → `infrastructure-db-oracle`)
|
||||||
|
|
||||||
---
|
- `application.yml` 또는 DB별 설정 파일(`application-postgres.yml`, `application-oracle.yml`)에서
|
||||||
|
데이터베이스 연결 정보 변경
|
||||||
|
|
||||||
### 💡 설계 포인트
|
- 실행 시 profile을 통해 DB 선택
|
||||||
|
|
||||||
- API 모듈은 DB 기술에 직접 의존하지 않음
|
```bash
|
||||||
- DB 관련 코드는 별도 모듈에서 관리
|
# PostgreSQL
|
||||||
- DB 변경 시 API 코드 수정 최소화
|
--spring.profiles.active=postgres
|
||||||
|
|
||||||
---
|
# Oracle
|
||||||
|
--spring.profiles.active=oracle
|
||||||
## 🔥 한 줄 요약
|
|
||||||
|
|
||||||
DB 기술 변경에 유연하게 대응하기 위해 persistence 레이어를 모듈로 분리했습니다.
|
|
||||||
|
|||||||
@@ -1,34 +1,41 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'org.springframework.boot'
|
id 'org.springframework.boot'
|
||||||
id 'io.spring.dependency-management'
|
|
||||||
id 'java'
|
id 'java'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// postgres
|
// DB 변경시 변경
|
||||||
implementation project(':infrastructure-db-postgres')
|
implementation project(':infrastructure-db-postgres')
|
||||||
|
|
||||||
|
// DB (테스트용)
|
||||||
|
runtimeOnly 'com.h2database:h2'
|
||||||
|
|
||||||
|
// Spring
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
|
||||||
|
// OpenAPI
|
||||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
|
||||||
|
|
||||||
|
// QueryDSL
|
||||||
implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
|
implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
|
||||||
|
|
||||||
|
// Lombok
|
||||||
compileOnly 'org.projectlombok:lombok'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
|
|
||||||
|
// Devtools
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
|
|
||||||
annotationProcessor 'org.projectlombok:lombok'
|
// QueryDSL APT
|
||||||
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
|
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
|
||||||
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
|
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
|
||||||
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
|
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
|
||||||
|
|
||||||
|
// Test
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named('test') {
|
|
||||||
useJUnitPlatform()
|
|
||||||
}
|
|
||||||
|
|
||||||
bootJar {
|
bootJar {
|
||||||
archiveFileName = 'ROOT.jar'
|
archiveFileName = 'ROOT.jar'
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,9 @@ package com.cd.detection;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class DabeeoDetectionApiApplication {
|
public class DabeeoDetectionApiApplication {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.cd.detection.config.api;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@ToString
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class ApiResponseDto<T> {
|
||||||
|
|
||||||
|
private final T data;
|
||||||
|
private final Error error;
|
||||||
|
|
||||||
|
private ApiResponseDto(T data, Error error) {
|
||||||
|
this.data = data;
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공
|
||||||
|
public static <T> ApiResponseDto<T> ok(T data) {
|
||||||
|
return new ApiResponseDto<>(data, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실패
|
||||||
|
public static <T> ApiResponseDto<T> fail(String code, String message) {
|
||||||
|
return new ApiResponseDto<>(null, new Error(code, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public static class Error {
|
||||||
|
private final String code;
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
public Error(String code, String message) {
|
||||||
|
this.code = code;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.cd.detection.db.core;
|
||||||
|
|
||||||
|
import com.cd.detection.db.repository.zoo.AnimalRepository;
|
||||||
|
import com.cd.detection.domain.zoo.dto.AnimalDto;
|
||||||
|
import com.cd.detection.domain.zoo.dto.AnimalDto.AddRequest;
|
||||||
|
import com.cd.detection.domain.zoo.dto.AnimalDto.ModifyRequest;
|
||||||
|
import com.cd.detection.domain.zoo.dto.AnimalDto.SearchListRequest;
|
||||||
|
import com.cd.detection.entity.AnimalEntity;
|
||||||
|
import com.cd.detection.entity.ZooEntity;
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AnimalCoreService {
|
||||||
|
private final AnimalRepository animalRepository;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void saveZoo(AddRequest dto) {
|
||||||
|
ZooEntity zoo = new ZooEntity();
|
||||||
|
zoo.setZooId(dto.getZooId()); // FK만 세팅
|
||||||
|
|
||||||
|
AnimalEntity animalEntity = new AnimalEntity();
|
||||||
|
animalEntity.setZoo(zoo);
|
||||||
|
animalEntity.setAnimalName(dto.getAnimalName());
|
||||||
|
animalEntity.setSpecies(dto.getSpecies());
|
||||||
|
animalEntity.setAge(dto.getAge());
|
||||||
|
|
||||||
|
animalRepository.save(animalEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateZoo(ModifyRequest dto) {
|
||||||
|
AnimalEntity animalEntity = animalRepository.findById(dto.getAnimalId()).orElseThrow(() -> new EntityNotFoundException("Animal Id: " + dto.getAnimalId()));
|
||||||
|
|
||||||
|
animalEntity.setAnimalName(dto.getAnimalName());
|
||||||
|
animalEntity.setSpecies(dto.getSpecies());
|
||||||
|
animalEntity.setUseYn(dto.getUseYn());
|
||||||
|
animalRepository.save(animalEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteZoo(ModifyRequest dto) {
|
||||||
|
AnimalEntity animalEntity = animalRepository.findById(dto.getAnimalId()).orElseThrow(() -> new EntityNotFoundException("Animal Id: " + dto.getAnimalId()));
|
||||||
|
|
||||||
|
animalEntity.setDelYn(dto.getDelYn());
|
||||||
|
animalRepository.save(animalEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<AnimalDto.Basic> getAnimals(SearchListRequest searchListRequest) {
|
||||||
|
Page<AnimalEntity> animals = animalRepository.findAllAnimals(searchListRequest);
|
||||||
|
|
||||||
|
return animals.map(a -> {
|
||||||
|
AnimalDto.Basic res = new AnimalDto.Basic();
|
||||||
|
res.setAnimalId(a.getAnimalId());
|
||||||
|
res.setAnimalName(a.getAnimalName());
|
||||||
|
res.setSpecies(a.getSpecies());
|
||||||
|
res.setAge(a.getAge() != null ? a.getAge().toString() : null);
|
||||||
|
res.setDelYn(a.getDelYn());
|
||||||
|
res.setUseYn(a.getUseYn());
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.cd.detection.db.core;
|
||||||
|
|
||||||
|
import com.cd.detection.db.repository.zoo.ZooRepository;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.AddRequest;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.AnimalRes;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.ModifyRequest;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.SearchListRequest;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.ZooWithAnimalsRes;
|
||||||
|
import com.cd.detection.entity.ZooEntity;
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ZooCoreService {
|
||||||
|
private final ZooRepository zooRepository;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void saveZoo(AddRequest dto) {
|
||||||
|
ZooEntity zooEntity = new ZooEntity();
|
||||||
|
zooEntity.setZooName(dto.getZooName());
|
||||||
|
zooEntity.setDescription(dto.getDescription());
|
||||||
|
|
||||||
|
zooRepository.save(zooEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateZoo(ModifyRequest dto) {
|
||||||
|
ZooEntity zooEntity = zooRepository.findById(dto.getId()).orElseThrow(() -> new EntityNotFoundException("Zoo Id: " + dto.getId()));
|
||||||
|
|
||||||
|
zooEntity.setZooName(dto.getZooName());
|
||||||
|
zooEntity.setDescription(dto.getDescription());
|
||||||
|
zooEntity.setUseYn(dto.getUseYn());
|
||||||
|
zooRepository.save(zooEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteZoo(ModifyRequest dto) {
|
||||||
|
ZooEntity zooEntity = zooRepository.findById(dto.getId()).orElseThrow(() -> new EntityNotFoundException("Zoo Id: " + dto.getId()));
|
||||||
|
|
||||||
|
zooEntity.setDelYn(dto.getDelYn());
|
||||||
|
zooRepository.save(zooEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<ZooWithAnimalsRes> getZooWithAnimals(SearchListRequest searchListRequest) {
|
||||||
|
Page<ZooEntity> zoo = zooRepository.findAllWithAnimals(searchListRequest);
|
||||||
|
|
||||||
|
return zoo.map(z -> {
|
||||||
|
ZooWithAnimalsRes res = new ZooWithAnimalsRes();
|
||||||
|
res.setZooId(z.getZooId());
|
||||||
|
res.setZooName(z.getZooName());
|
||||||
|
res.setDescription(z.getDescription());
|
||||||
|
|
||||||
|
List<AnimalRes> animals = z.getAnimals().stream()
|
||||||
|
.map(a -> {
|
||||||
|
AnimalRes ar = new AnimalRes();
|
||||||
|
ar.setAnimalId(a.getAnimalId());
|
||||||
|
ar.setAnimalName(a.getAnimalName());
|
||||||
|
ar.setSpecies(a.getSpecies());
|
||||||
|
ar.setAge(a.getAge());
|
||||||
|
return ar;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
res.setAnimals(animals);
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cd.detection.db.repository.zoo;
|
||||||
|
|
||||||
|
import com.cd.detection.entity.AnimalEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface AnimalRepository extends JpaRepository<AnimalEntity, Long>, AnimalRepositoryCustom {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.cd.detection.db.repository.zoo;
|
||||||
|
|
||||||
|
import com.cd.detection.domain.zoo.dto.AnimalDto;
|
||||||
|
import com.cd.detection.entity.AnimalEntity;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
|
||||||
|
public interface AnimalRepositoryCustom {
|
||||||
|
Page<AnimalEntity> findAllAnimals(AnimalDto.SearchListRequest dto);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.cd.detection.db.repository.zoo;
|
||||||
|
|
||||||
|
import com.cd.detection.domain.zoo.dto.AnimalDto.SearchListRequest;
|
||||||
|
import com.cd.detection.entity.AnimalEntity;
|
||||||
|
import com.cd.detection.entity.QAnimalEntity;
|
||||||
|
import com.cd.detection.entity.QZooEntity;
|
||||||
|
import com.cd.detection.entity.ZooEntity;
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class AnimalRepositoryImpl implements AnimalRepositoryCustom {
|
||||||
|
private final JPAQueryFactory queryFactory;
|
||||||
|
|
||||||
|
public AnimalRepositoryImpl(JPAQueryFactory queryFactory) {
|
||||||
|
this.queryFactory = queryFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<AnimalEntity> findAllAnimals(SearchListRequest searchListRequest) {
|
||||||
|
Pageable pageable = searchListRequest.toPageable();
|
||||||
|
QAnimalEntity animal = QAnimalEntity.animalEntity;
|
||||||
|
|
||||||
|
// content 조회
|
||||||
|
List<AnimalEntity> content = queryFactory
|
||||||
|
.selectFrom(animal)
|
||||||
|
.where(animal.delYn.eq(false))
|
||||||
|
.distinct()
|
||||||
|
.offset(pageable.getOffset())
|
||||||
|
.limit(pageable.getPageSize())
|
||||||
|
.fetch();
|
||||||
|
|
||||||
|
// count 조회 (fetchJoin 제거)
|
||||||
|
Long total = queryFactory
|
||||||
|
.select(animal.count())
|
||||||
|
.from(animal)
|
||||||
|
.where(animal.delYn.eq(false))
|
||||||
|
.fetchOne();
|
||||||
|
|
||||||
|
return new PageImpl<>(content, pageable, total == null ? 0L : total);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cd.detection.db.repository.zoo;
|
||||||
|
|
||||||
|
import com.cd.detection.entity.ZooEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface ZooRepository extends JpaRepository<ZooEntity, Long>, ZooRepositoryCustom {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.cd.detection.db.repository.zoo;
|
||||||
|
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.SearchListRequest;
|
||||||
|
import com.cd.detection.entity.ZooEntity;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
|
||||||
|
public interface ZooRepositoryCustom {
|
||||||
|
Page<ZooEntity> findAllWithAnimals(SearchListRequest searchListRequest);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.cd.detection.db.repository.zoo;
|
||||||
|
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.SearchListRequest;
|
||||||
|
import com.cd.detection.entity.QZooEntity;
|
||||||
|
import com.cd.detection.entity.ZooEntity;
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class ZooRepositoryImpl implements ZooRepositoryCustom {
|
||||||
|
|
||||||
|
private final JPAQueryFactory queryFactory;
|
||||||
|
|
||||||
|
public ZooRepositoryImpl(JPAQueryFactory queryFactory) {
|
||||||
|
this.queryFactory = queryFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PageImpl<ZooEntity> findAllWithAnimals(SearchListRequest searchListRequest) {
|
||||||
|
Pageable pageable = searchListRequest.toPageable();
|
||||||
|
QZooEntity zoo = QZooEntity.zooEntity;
|
||||||
|
|
||||||
|
// content 조회
|
||||||
|
List<ZooEntity> content = queryFactory
|
||||||
|
.selectFrom(zoo)
|
||||||
|
.leftJoin(zoo.animals).fetchJoin()
|
||||||
|
.where(zoo.delYn.eq(false))
|
||||||
|
.distinct()
|
||||||
|
.offset(pageable.getOffset())
|
||||||
|
.limit(pageable.getPageSize())
|
||||||
|
.fetch();
|
||||||
|
|
||||||
|
// count 조회 (fetchJoin 제거)
|
||||||
|
Long total = queryFactory
|
||||||
|
.select(zoo.count())
|
||||||
|
.from(zoo)
|
||||||
|
.where(zoo.delYn.eq(false))
|
||||||
|
.fetchOne();
|
||||||
|
|
||||||
|
return new PageImpl<>(content, pageable, total == null ? 0L : total);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.cd.detection.domain.zoo;
|
||||||
|
|
||||||
|
import com.cd.detection.config.api.ApiResponseDto;
|
||||||
|
import com.cd.detection.domain.zoo.dto.AnimalDto;
|
||||||
|
import com.cd.detection.domain.zoo.service.AnimalService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Tag(name = "동물", description = "동물 API")
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/api/animal")
|
||||||
|
public class AnimalController {
|
||||||
|
|
||||||
|
private final AnimalService animalService;
|
||||||
|
|
||||||
|
@Operation(summary = "동물 목록", description = "동물 목록 ")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "검색 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = Page.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@GetMapping("/list")
|
||||||
|
public ApiResponseDto<Page<AnimalDto.Basic>> getZooWithAnimals(
|
||||||
|
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0")
|
||||||
|
@RequestParam(defaultValue = "0")
|
||||||
|
int page,
|
||||||
|
@Parameter(description = "페이지 크기", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20")
|
||||||
|
int size) {
|
||||||
|
|
||||||
|
AnimalDto.SearchListRequest searchListRequest = new AnimalDto.SearchListRequest();
|
||||||
|
searchListRequest.setPage(page);
|
||||||
|
searchListRequest.setSize(size);
|
||||||
|
return ApiResponseDto.ok(animalService.getAnimals(searchListRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "동물 등록", description = "동물 등록 ")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "등록 성공",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = "text/plain",
|
||||||
|
schema = @Schema(type = "string", example = "success")
|
||||||
|
)),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponseDto<String> saveAnimal(
|
||||||
|
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||||
|
content = @Content(
|
||||||
|
schema = @Schema(implementation = AnimalDto.AddRequest.class)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@RequestBody AnimalDto.AddRequest dto) {
|
||||||
|
animalService.saveAnimal(dto);
|
||||||
|
return ApiResponseDto.ok("success");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "동물 수정", description = "동물 수정 ")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "수정 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = String.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@PutMapping
|
||||||
|
public ApiResponseDto<String> updateAnimal(@RequestBody AnimalDto.ModifyRequest dto) {
|
||||||
|
animalService.updateAnimal(dto);
|
||||||
|
return ApiResponseDto.ok("success");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "동물 삭제", description = "동물 삭제 ")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "삭제 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = String.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@DeleteMapping
|
||||||
|
public ApiResponseDto<String> deleteAnimal(@RequestBody AnimalDto.ModifyRequest dto) {
|
||||||
|
animalService.deleteAnimal(dto);
|
||||||
|
return ApiResponseDto.ok("success");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.cd.detection.domain.zoo;
|
||||||
|
|
||||||
|
import com.cd.detection.config.api.ApiResponseDto;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.SearchListRequest;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.ZooWithAnimalsRes;
|
||||||
|
import com.cd.detection.domain.zoo.service.ZooService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Tag(name = "동물원", description = "동물원 API")
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/api/zoo")
|
||||||
|
public class ZooController {
|
||||||
|
|
||||||
|
private final ZooService zooService;
|
||||||
|
|
||||||
|
@Operation(summary = "동물원 목록", description = "동물원 목록 ")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "검색 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = Page.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@GetMapping("/list")
|
||||||
|
public ApiResponseDto<Page<ZooWithAnimalsRes>> getZooWithAnimals(
|
||||||
|
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0")
|
||||||
|
@RequestParam(defaultValue = "0")
|
||||||
|
int page,
|
||||||
|
@Parameter(description = "페이지 크기", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20")
|
||||||
|
int size) {
|
||||||
|
|
||||||
|
SearchListRequest searchListRequest = new SearchListRequest();
|
||||||
|
searchListRequest.setPage(page);
|
||||||
|
searchListRequest.setSize(size);
|
||||||
|
return ApiResponseDto.ok(zooService.getZooWithAnimals(searchListRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "동물원 등록", description = "동물원 등록 ")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "등록 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = String.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponseDto<String> saveZoo(@RequestBody ZooDto.AddRequest dto) {
|
||||||
|
zooService.saveZoo(dto);
|
||||||
|
return ApiResponseDto.ok("success");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "동물원 수정", description = "동물원 수정 ")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "수정 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = String.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@PutMapping
|
||||||
|
public ApiResponseDto<String> updateZoo(@RequestBody ZooDto.ModifyRequest dto) {
|
||||||
|
zooService.updateZoo(dto);
|
||||||
|
return ApiResponseDto.ok("success");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "동물원 삭제", description = "동물원 삭제 ")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "삭제 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = String.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@DeleteMapping
|
||||||
|
public ApiResponseDto<String> deleteZoo(@RequestBody ZooDto.ModifyRequest dto) {
|
||||||
|
zooService.deleteZoo(dto);
|
||||||
|
return ApiResponseDto.ok("success");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.cd.detection.domain.zoo.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
public class AnimalDto {
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class SearchListRequest {
|
||||||
|
// 페이징 파라미터
|
||||||
|
private int page = 0;
|
||||||
|
private int size = 20;
|
||||||
|
|
||||||
|
public Pageable toPageable() {
|
||||||
|
return PageRequest.of(page, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class AddRequest {
|
||||||
|
|
||||||
|
@Schema(description = "동물원 id")
|
||||||
|
private Long zooId;
|
||||||
|
@Schema(description = "동물명")
|
||||||
|
private String animalName;
|
||||||
|
@Schema(description = "종")
|
||||||
|
private String species;
|
||||||
|
@Schema(description = "나이")
|
||||||
|
private Integer age;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class ModifyRequest {
|
||||||
|
|
||||||
|
@Schema(description = "동물 id")
|
||||||
|
private Long animalId;
|
||||||
|
@Schema(description = "동물명")
|
||||||
|
private String animalName;
|
||||||
|
@Schema(description = "종")
|
||||||
|
private String species;
|
||||||
|
@Schema(description = "나이")
|
||||||
|
private String age;
|
||||||
|
@Schema(description = "삭제여부")
|
||||||
|
private Boolean delYn;
|
||||||
|
@Schema(description = "사용여부")
|
||||||
|
private Boolean useYn;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class Basic {
|
||||||
|
|
||||||
|
@Schema(description = "동물 id")
|
||||||
|
private Long animalId;
|
||||||
|
@Schema(description = "동물명")
|
||||||
|
private String animalName;
|
||||||
|
@Schema(description = "종")
|
||||||
|
private String species;
|
||||||
|
@Schema(description = "나이")
|
||||||
|
private String age;
|
||||||
|
@Schema(description = "삭제여부")
|
||||||
|
private Boolean delYn;
|
||||||
|
@Schema(description = "사용여부")
|
||||||
|
private Boolean useYn;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.cd.detection.domain.zoo.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
public class ZooDto {
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class SearchListRequest {
|
||||||
|
// 페이징 파라미터
|
||||||
|
private int page = 0;
|
||||||
|
private int size = 20;
|
||||||
|
|
||||||
|
public Pageable toPageable() {
|
||||||
|
return PageRequest.of(page, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class AddRequest {
|
||||||
|
|
||||||
|
private String zooName;
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class ModifyRequest {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String zooName;
|
||||||
|
private String description;
|
||||||
|
private Boolean useYn;
|
||||||
|
private Boolean delYn;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class ZooWithAnimalsRes {
|
||||||
|
|
||||||
|
private Long zooId; // 동물원 ID
|
||||||
|
private String zooName; // 동물원 이름
|
||||||
|
private String description; // 설명
|
||||||
|
|
||||||
|
private List<AnimalRes> animals;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class AnimalRes {
|
||||||
|
private Long animalId;
|
||||||
|
private String animalName;
|
||||||
|
private String species;
|
||||||
|
private Integer age;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.cd.detection.domain.zoo.service;
|
||||||
|
|
||||||
|
import com.cd.detection.db.core.AnimalCoreService;
|
||||||
|
import com.cd.detection.domain.zoo.dto.AnimalDto;
|
||||||
|
import com.cd.detection.domain.zoo.dto.AnimalDto.Basic;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AnimalService {
|
||||||
|
|
||||||
|
private final AnimalCoreService animalCoreService;
|
||||||
|
|
||||||
|
public Page<Basic> getAnimals(AnimalDto.SearchListRequest searchListRequest) {
|
||||||
|
return animalCoreService.getAnimals(searchListRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveAnimal(AnimalDto.AddRequest dto) {
|
||||||
|
animalCoreService.saveZoo(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateAnimal(AnimalDto.ModifyRequest dto) {
|
||||||
|
animalCoreService.updateZoo(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteAnimal(AnimalDto.ModifyRequest dto) {
|
||||||
|
animalCoreService.deleteZoo(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.cd.detection.domain.zoo.service;
|
||||||
|
|
||||||
|
import com.cd.detection.db.core.ZooCoreService;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.AddRequest;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.ModifyRequest;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.SearchListRequest;
|
||||||
|
import com.cd.detection.domain.zoo.dto.ZooDto.ZooWithAnimalsRes;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ZooService {
|
||||||
|
private final ZooCoreService zooCoreService;
|
||||||
|
|
||||||
|
// 동물원, 동물 조회
|
||||||
|
public Page<ZooWithAnimalsRes> getZooWithAnimals(SearchListRequest searchListRequest) {
|
||||||
|
return zooCoreService.getZooWithAnimals(searchListRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동물원 저장
|
||||||
|
public void saveZoo(AddRequest dto) {
|
||||||
|
zooCoreService.saveZoo(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동물원 수정
|
||||||
|
public void updateZoo(ModifyRequest dto) {
|
||||||
|
zooCoreService.updateZoo(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동물원 삭제
|
||||||
|
public void deleteZoo(ModifyRequest dto) {
|
||||||
|
zooCoreService.deleteZoo(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.cd.detection.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.Comment;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Table(name = "tb_animal")
|
||||||
|
@Comment("동물 정보")
|
||||||
|
public class AnimalEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "animal_id")
|
||||||
|
@Comment("동물 ID")
|
||||||
|
private Long animalId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동물원 엔티티 (FK)
|
||||||
|
*/
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "zoo_id", nullable = false)
|
||||||
|
@Comment("동물원 ID (FK)")
|
||||||
|
private ZooEntity zoo;
|
||||||
|
|
||||||
|
@Column(name = "animal_name", nullable = false)
|
||||||
|
@Comment("동물 이름")
|
||||||
|
private String animalName;
|
||||||
|
|
||||||
|
@Column(name = "species")
|
||||||
|
@Comment("종")
|
||||||
|
private String species;
|
||||||
|
|
||||||
|
@Column(name = "age")
|
||||||
|
@Comment("나이")
|
||||||
|
private Integer age;
|
||||||
|
|
||||||
|
@Column(name = "use_yn", nullable = false)
|
||||||
|
@Comment("사용 여부")
|
||||||
|
private Boolean useYn = true;
|
||||||
|
|
||||||
|
@Column(name = "del_yn", nullable = false)
|
||||||
|
@Comment("삭제 여부")
|
||||||
|
private Boolean delYn = false;
|
||||||
|
|
||||||
|
@Column(name = "created_dttm", nullable = false)
|
||||||
|
@Comment("생성 일시")
|
||||||
|
private ZonedDateTime createdDttm = ZonedDateTime.now();
|
||||||
|
|
||||||
|
@Column(name = "updated_dttm", nullable = false)
|
||||||
|
@Comment("수정 일시")
|
||||||
|
private ZonedDateTime updatedDttm = ZonedDateTime.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.cd.detection.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.hibernate.annotations.Comment;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tb_zoo")
|
||||||
|
public class ZooEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "zoo_id")
|
||||||
|
@Comment("동물원 ID")
|
||||||
|
private Long zooId;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "zoo", fetch = FetchType.LAZY)
|
||||||
|
private List<AnimalEntity> animals = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(name = "zoo_name", nullable = false, length = 200)
|
||||||
|
@Comment("동물원 이름")
|
||||||
|
private String zooName;
|
||||||
|
|
||||||
|
@Column(name = "description")
|
||||||
|
@Comment("설명")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "use_yn", nullable = false)
|
||||||
|
@Comment("사용 여부")
|
||||||
|
private Boolean useYn = true;
|
||||||
|
|
||||||
|
@Column(name = "del_yn", nullable = false)
|
||||||
|
@Comment("삭제 여부")
|
||||||
|
private Boolean delYn = false;
|
||||||
|
|
||||||
|
@Column(name = "created_dttm", nullable = false, updatable = false)
|
||||||
|
@Comment("생성 일시")
|
||||||
|
private ZonedDateTime createdDttm = ZonedDateTime.now();
|
||||||
|
|
||||||
|
@Column(name = "updated_dttm", nullable = false)
|
||||||
|
@Comment("수정 일시")
|
||||||
|
private ZonedDateTime updatedDttm = ZonedDateTime.now();
|
||||||
|
|
||||||
|
public void addAnimal(AnimalEntity animal) {
|
||||||
|
animals.add(animal);
|
||||||
|
animal.setZoo(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
spring:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://localhost:5432/dabeeo_detection_dev
|
url: jdbc:h2:mem:testdb
|
||||||
username: dabeeo_detection
|
username: sa
|
||||||
password: 1234
|
password:
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.H2Dialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: create-drop
|
||||||
|
|
||||||
|
h2:
|
||||||
|
console:
|
||||||
|
enabled: true
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
spring:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://localhost:5432/dabeeo_detection_dev
|
url: jdbc:h2:mem:testdb
|
||||||
username: dabeeo_detection
|
username: sa
|
||||||
password: 1234
|
password:
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.H2Dialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: create-drop
|
||||||
|
|
||||||
|
h2:
|
||||||
|
console:
|
||||||
|
enabled: true
|
||||||
@@ -9,3 +9,12 @@ spring:
|
|||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
format_sql: true
|
format_sql: true
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
base-path: /monitor
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
probes:
|
||||||
|
enabled: true
|
||||||
@@ -1,26 +1,30 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'java'
|
|
||||||
id 'io.spring.dependency-management' version '1.1.7' apply false
|
|
||||||
id 'org.springframework.boot' version '3.5.7' apply false
|
id 'org.springframework.boot' version '3.5.7' apply false
|
||||||
|
id 'io.spring.dependency-management' version '1.1.7' apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
group = 'com.cd.detection'
|
allprojects {
|
||||||
version = '0.0.1-SNAPSHOT'
|
group = 'com.cd.detection'
|
||||||
|
version = '0.0.1-SNAPSHOT'
|
||||||
java {
|
|
||||||
toolchain {
|
|
||||||
languageVersion = JavaLanguageVersion.of(21)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
|
|
||||||
subprojects {
|
|
||||||
apply plugin: 'java'
|
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
apply plugin: 'java'
|
||||||
|
apply plugin: 'io.spring.dependency-management'
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyManagement {
|
||||||
|
imports {
|
||||||
|
mavenBom "org.springframework.boot:spring-boot-dependencies:3.5.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,6 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
// DB 드라이버
|
||||||
runtimeOnly 'org.postgresql:postgresql'
|
implementation 'org.postgresql:postgresql:42.7.3'
|
||||||
}
|
}
|
||||||
19
web-app/.storybook/main.ts
Normal file
19
web-app/.storybook/main.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/react-vite'
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../app/**/*.stories.@(ts|tsx)'],
|
||||||
|
|
||||||
|
framework: '@storybook/react-vite',
|
||||||
|
|
||||||
|
// Storybook 전용 Vite 설정 파일 사용 (React Router 플러그인 제외)
|
||||||
|
core: {
|
||||||
|
builder: {
|
||||||
|
name: '@storybook/builder-vite',
|
||||||
|
options: {
|
||||||
|
viteConfigPath: '.storybook/vite.config.ts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
16
web-app/.storybook/preview.ts
Normal file
16
web-app/.storybook/preview.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Preview } from '@storybook/react'
|
||||||
|
|
||||||
|
import '../app/app.css'
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default preview
|
||||||
13
web-app/.storybook/vite.config.ts
Normal file
13
web-app/.storybook/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// Storybook 전용 Vite 설정 (React Router 플러그인 제외)
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~': new URL('../app', import.meta.url).pathname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,9 +1,89 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css";
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("~/shared/assets/fonts/Pretendard-Regular.subset.woff") format("woff");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("~/shared/assets/fonts/Pretendard-Medium.subset.woff") format("woff");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("~/shared/assets/fonts/Pretendard-SemiBold.subset.woff") format("woff");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("~/shared/assets/fonts/Pretendard-Bold.subset.woff") format("woff");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Pretendard Variable", ui-sans-serif, system-ui, sans-serif,
|
--font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif,
|
||||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
|
||||||
|
--color-dabeeo-blue: #2768ff;
|
||||||
|
--color-dabeeo-red: #ff0000;
|
||||||
|
--color-dabeeo-yellow: #fffce8;
|
||||||
|
|
||||||
|
/* Primary (Navy) */
|
||||||
|
--color-primary: var(--color-dabeeo-navy-main);
|
||||||
|
--color-primary-secondary: var(--color-dabeeo-navy-secondary);
|
||||||
|
--color-primary-tertiary: var(--color-dabeeo-navy-tertiary);
|
||||||
|
--color-primary-tertiary01: var(--color-dabeeo-navy-tertiary01);
|
||||||
|
--color-primary-tertiary02: var(--color-dabeeo-navy-tertiary02);
|
||||||
|
|
||||||
|
/* Navy */
|
||||||
|
--color-dabeeo-navy-main: #00387d;
|
||||||
|
--color-dabeeo-navy-secondary: #032651;
|
||||||
|
--color-dabeeo-navy-tertiary: #5c84b4;
|
||||||
|
--color-dabeeo-navy-tertiary01: #d4dde9;
|
||||||
|
--color-dabeeo-navy-tertiary02: #f0f3f7;
|
||||||
|
|
||||||
|
/* Green */
|
||||||
|
--color-dabeeo-green-main: #1b8466;
|
||||||
|
--color-dabeeo-green-secondary: #125a46;
|
||||||
|
--color-dabeeo-green-tertiary: #89bea7;
|
||||||
|
--color-dabeeo-green-tertiary01: #d1e6e1;
|
||||||
|
--color-dabeeo-green-tertiary02: #ebf2f0;
|
||||||
|
|
||||||
|
/* Orange */
|
||||||
|
--color-dabeeo-orange-main: #ff7937;
|
||||||
|
--color-dabeeo-orange-secondary: #c14b11;
|
||||||
|
--color-dabeeo-orange-tertiary01: #fff2eb;
|
||||||
|
--color-dabeeo-orange-tertiary02: #ffe4d7;
|
||||||
|
|
||||||
|
/* Yellow */
|
||||||
|
--color-dabeeo-yellow-main: #ffb724;
|
||||||
|
--color-dabeeo-yellow-secondary: #fffce8;
|
||||||
|
|
||||||
|
/* Gray */
|
||||||
|
--color-dabeeo-gray-44: #444444;
|
||||||
|
--color-dabeeo-gray-99: #999999;
|
||||||
|
--color-dabeeo-gray-be: #bebebe;
|
||||||
|
--color-dabeeo-gray-da: #dadada;
|
||||||
|
--color-dabeeo-gray-eb: #ebebeb;
|
||||||
|
--color-dabeeo-gray-f9: #f9f9f9;
|
||||||
|
|
||||||
|
--color-dabeeo-black-22: #222222;
|
||||||
|
--color-dabeeo-black-34: #343434;
|
||||||
|
--color-dabeeo-black-47: #475954;
|
||||||
|
--color-dabeeo-black-2a: #2a2e35;
|
||||||
|
--color-dabeeo-black-4d: #4d5562;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|||||||
87
web-app/app/features/imagery/api/aerial.ts
Normal file
87
web-app/app/features/imagery/api/aerial.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type { ApiResponse, PagedResponse } from '~/shared/types/api';
|
||||||
|
import type {
|
||||||
|
AerialData,
|
||||||
|
AerialDetail,
|
||||||
|
AerialItem,
|
||||||
|
AerialListParams,
|
||||||
|
} from '../types/aerial';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항공영상 목록 조회
|
||||||
|
*/
|
||||||
|
export const fetchAerialList = async (params: AerialListParams) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<ApiResponse<PagedResponse<AerialItem>>>('/api/imagery/aerial/list', {
|
||||||
|
params: {
|
||||||
|
dateRangeType: params.dateRangeType,
|
||||||
|
strtDttm: params.strtDttm,
|
||||||
|
endDttm: params.endDttm,
|
||||||
|
page: params.page ?? 0,
|
||||||
|
size: params.size ?? 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: response.data.data.content,
|
||||||
|
pagination: {
|
||||||
|
currentPage: response.data.data.number,
|
||||||
|
pageSize: response.data.data.size,
|
||||||
|
totalPages: response.data.data.totalPages,
|
||||||
|
totalItems: response.data.data.totalElements,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('fetchAerialList error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항공영상 상세 요약정보 조회
|
||||||
|
*/
|
||||||
|
export const fetchAerialDetail = async (uuid: string) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<ApiResponse<AerialDetail>>(`/api/imagery/aerial/detail/${uuid}`);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('fetchAerialDetail error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항공영상 상세 영상 조회 (이미지 다운로드 URL 반환)
|
||||||
|
*/
|
||||||
|
export const getAerialImageUrl = (uuid: string, imageType: 'before' | 'after' = 'before') => {
|
||||||
|
return `/api/imagery/detail/image?uuid=${uuid}&imageType=${imageType}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항공영상 상세 영상 조회 (Blob으로 다운로드)
|
||||||
|
*/
|
||||||
|
export const fetchAerialImage = async (uuid: string, imageType: 'before' | 'after' = 'before') => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/imagery/detail/image', {
|
||||||
|
params: { uuid, imageType },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('fetchAerialImage error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항공영상 데이터 등록
|
||||||
|
*/
|
||||||
|
export const registerAerialData = async (data: AerialData) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/imagery/aerial', data);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('registerAerialData error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
web-app/app/features/imagery/api/imagery.ts
Normal file
74
web-app/app/features/imagery/api/imagery.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type { ApiResponse } from '~/shared/types/api';
|
||||||
|
import type { ChunkUploadParams, ChunkUploadResponse, FolderListResponse, Region } from '../types/imageryRegister';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 지역 조회 (시/도)
|
||||||
|
*/
|
||||||
|
export const fetchRegions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<ApiResponse<Region[]>>('/api/imagery/regions/provinces');
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('fetchRegions error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 폴더 조회
|
||||||
|
*/
|
||||||
|
export const fetchFolderList = async (dirPath: string) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post<ApiResponse<FolderListResponse>>('/api/imagery/folder-list', {
|
||||||
|
dirPath,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('fetchFolderList error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대용량 파일 분할 전송 (청크 업로드)
|
||||||
|
*/
|
||||||
|
export const uploadFileChunk = async (params: ChunkUploadParams) => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('fileName', params.fileName);
|
||||||
|
formData.append('fileSize', String(params.fileSize));
|
||||||
|
formData.append('chunkIndex', String(params.chunkIndex));
|
||||||
|
formData.append('chunkTotalIndex', String(params.chunkTotalIndex));
|
||||||
|
formData.append('chunkFile', params.chunkFile);
|
||||||
|
|
||||||
|
const response = await axios.post<ApiResponse<ChunkUploadResponse>>(
|
||||||
|
'/api/imagery/upload/file-chunk-upload',
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('uploadFileChunk error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 완료된 파일 병합
|
||||||
|
*/
|
||||||
|
export const completeChunkUpload = async (uuid: string) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put<ApiResponse<ChunkUploadResponse>>(
|
||||||
|
`/api/imagery/upload/chunk-upload-complete/${uuid}`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('completeChunkUpload error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
114
web-app/app/features/imagery/components/AerialList.tsx
Normal file
114
web-app/app/features/imagery/components/AerialList.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '~/shared/components/button/Button';
|
||||||
|
import { Section } from '~/shared/components/section/Section';
|
||||||
|
import { Table } from '~/shared/components/table';
|
||||||
|
import type { AerialItem } from '../types/aerial';
|
||||||
|
import { AerialRegisterModal } from './AerialRegisterModal';
|
||||||
|
|
||||||
|
export function AerialList() {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<AerialItem[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// const result = await fetchAerialList('YEAR', strtDttm, endDttm);
|
||||||
|
const result = {
|
||||||
|
list: [],
|
||||||
|
pagination: {
|
||||||
|
currentPage: 0,
|
||||||
|
pagiSize: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
totalItems: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (result) {
|
||||||
|
setData(result.list);
|
||||||
|
setTotalCount(result.pagination.totalItems);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section className="w-full pb-4" variant="list">
|
||||||
|
<div className="flex flex-col h-full p-4 gap-4">
|
||||||
|
<h1 className="text-xl font-bold">항공영상관리</h1>
|
||||||
|
|
||||||
|
<Table isLayoutFixed isFullHeight>
|
||||||
|
<Table.Caption>
|
||||||
|
<Table.CaptionLeft>
|
||||||
|
<Table.Total count={totalCount} />
|
||||||
|
</Table.CaptionLeft>
|
||||||
|
<Table.CaptionRight>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button color="primary" onClick={() => setIsRegisterModalOpen(true)}>항공영상 등록</Button>
|
||||||
|
</div>
|
||||||
|
</Table.CaptionRight>
|
||||||
|
</Table.Caption>
|
||||||
|
|
||||||
|
<Table.Container>
|
||||||
|
<Table.Colgroup>
|
||||||
|
<Table.Col width={60} />
|
||||||
|
<Table.Col width={180} />
|
||||||
|
<Table.Col width={200} />
|
||||||
|
<Table.Col width={120} />
|
||||||
|
<Table.Col width={100} />
|
||||||
|
<Table.Col width={120} />
|
||||||
|
<Table.Col width={80} />
|
||||||
|
</Table.Colgroup>
|
||||||
|
|
||||||
|
<Table.Header>
|
||||||
|
<Table.HeaderRow>
|
||||||
|
<Table.HeaderCell align="center">No</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>파일명</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>지역</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell align="center">촬영일시</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell align="center">축적</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell align="right">용량</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell align="center">전처리</Table.HeaderCell>
|
||||||
|
</Table.HeaderRow>
|
||||||
|
</Table.Header>
|
||||||
|
|
||||||
|
<Table.Body>
|
||||||
|
{isLoading ? (
|
||||||
|
<Table.Loading colSpan={7} />
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<Table.Empty colSpan={7}>등록된 항공영상이 없습니다.</Table.Empty>
|
||||||
|
) : (
|
||||||
|
data.map((item, index) => (
|
||||||
|
<Table.Row
|
||||||
|
key={item.mapId}
|
||||||
|
isSelected={selectedId === item.mapId}
|
||||||
|
onClick={() => setSelectedId(item.mapId)}
|
||||||
|
>
|
||||||
|
<Table.Cell align="center">{index + 1}</Table.Cell>
|
||||||
|
<Table.Cell>{item.fileName}</Table.Cell>
|
||||||
|
<Table.Cell>{item.region}</Table.Cell>
|
||||||
|
<Table.Cell align="center">{item.capturedDttm}</Table.Cell>
|
||||||
|
<Table.Cell align="center">{item.scale}</Table.Cell>
|
||||||
|
<Table.Cell align="right">{item.fileSize}</Table.Cell>
|
||||||
|
<Table.Cell align="center">
|
||||||
|
{item.status}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Container>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
{isRegisterModalOpen && <AerialRegisterModal isOpen={isRegisterModalOpen} onClose={() => setIsRegisterModalOpen(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
423
web-app/app/features/imagery/components/AerialRegisterModal.tsx
Normal file
423
web-app/app/features/imagery/components/AerialRegisterModal.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Button } from '~/shared/components/button/Button';
|
||||||
|
import { Modal, ModalRegion } from '~/shared/components/modal';
|
||||||
|
import { Select, type SelectOption } from '~/shared/components/select/Select';
|
||||||
|
import { Tree, type TreeItemType, type TreeRef } from '~/shared/components/tree/Tree';
|
||||||
|
|
||||||
|
import { fetchFolderList, fetchRegions } from '../api/imagery';
|
||||||
|
import { useAerialChunkUpload, type UploadingFile } from '../hooks/useAerialChunkUpload';
|
||||||
|
|
||||||
|
type AerialRegisterModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUploadSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
type: string;
|
||||||
|
region: string;
|
||||||
|
folderPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: SelectOption[] = [
|
||||||
|
{ value: 'mapFrame', label: '도곽' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))}${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProgressBar = ({ progress }: { progress: number }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 h-2 bg-dabeeo-gray-eb overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileItem = ({
|
||||||
|
file,
|
||||||
|
folderPath,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
file: UploadingFile;
|
||||||
|
folderPath: string;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) => {
|
||||||
|
const fullPath = folderPath ? `${folderPath}/${file.fileName}` : file.fileName;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<span className="text-sm text-dabeeo-black-34 truncate flex-1">{fullPath}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(file.id)}
|
||||||
|
className="text-dabeeo-gray-99 hover:text-dabeeo-black-34 cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12.5 4L4 12.5M4 4L12.5 12.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AerialRegisterModal = ({ isOpen, onClose, onUploadSuccess }: AerialRegisterModalProps) => {
|
||||||
|
const treeRef = useRef<TreeRef | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { control, setValue, watch, reset: resetForm } = useForm<FormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
type: 'mapFrame',
|
||||||
|
region: '',
|
||||||
|
folderPath: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRegion = watch('region');
|
||||||
|
const selectedFolder = watch('folderPath');
|
||||||
|
|
||||||
|
const [regions, setRegions] = useState<SelectOption[]>([]);
|
||||||
|
const [treeItems, setTreeItems] = useState<TreeItemType[]>([]);
|
||||||
|
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
addFiles,
|
||||||
|
startUpload,
|
||||||
|
removeFile,
|
||||||
|
reset: resetUpload,
|
||||||
|
isUploading,
|
||||||
|
isAllCompleted,
|
||||||
|
hasFiles,
|
||||||
|
totalSize,
|
||||||
|
uploadedSize,
|
||||||
|
overallProgress,
|
||||||
|
} = useAerialChunkUpload();
|
||||||
|
|
||||||
|
const memorizedSelectedKeys = useMemo(
|
||||||
|
() => new Set(selectedFolder ? [selectedFolder] : []),
|
||||||
|
[selectedFolder]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 지역 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
const loadRegions = async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchRegions();
|
||||||
|
setRegions(data.map((r) => ({ value: r.code, label: r.name })));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load regions:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
loadRegions();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 지역 선택 시 폴더 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedRegion) {
|
||||||
|
setTreeItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFolders = async () => {
|
||||||
|
setIsLoadingFolders(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchFolderList(selectedRegion);
|
||||||
|
const items: TreeItemType[] = data.folders.map((folder) => ({
|
||||||
|
id: folder.fullPath,
|
||||||
|
title: folder.folderNm,
|
||||||
|
type: 'directory',
|
||||||
|
}));
|
||||||
|
setTreeItems(items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load folders:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingFolders(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadFolders();
|
||||||
|
}, [selectedRegion]);
|
||||||
|
|
||||||
|
const handleExpandFolder = async (key: string | number) => {
|
||||||
|
const folderPath = String(key);
|
||||||
|
try {
|
||||||
|
const data = await fetchFolderList(folderPath);
|
||||||
|
data.folders.forEach((folder) => {
|
||||||
|
const newItem: TreeItemType = {
|
||||||
|
id: folder.fullPath,
|
||||||
|
title: folder.folderNm,
|
||||||
|
type: 'directory',
|
||||||
|
};
|
||||||
|
treeRef.current?.addItem(newItem, folderPath);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load subfolders:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCollapseFolder = (key: string | number) => {
|
||||||
|
treeRef.current?.clearItemsByParentKey(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFiles = e.target.files;
|
||||||
|
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
const fileArray = Array.from(selectedFiles);
|
||||||
|
const addedFiles = addFiles(fileArray);
|
||||||
|
|
||||||
|
// 파일 선택 즉시 업로드 시작
|
||||||
|
startUpload(addedFiles);
|
||||||
|
|
||||||
|
// input 초기화 (같은 파일 재선택 가능하도록)
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
if (!selectedFolder) {
|
||||||
|
ModalRegion.alert({ content: '업로드할 폴더를 선택해주세요.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasFiles) {
|
||||||
|
ModalRegion.alert({ content: '업로드할 파일을 선택해주세요.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUploading) {
|
||||||
|
ModalRegion.alert({ content: '파일 업로드가 진행 중입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAllCompleted) {
|
||||||
|
ModalRegion.alert({
|
||||||
|
content: '파일 업로드가 완료되었습니다.',
|
||||||
|
onCancel: () => {
|
||||||
|
onUploadSuccess?.();
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetUpload();
|
||||||
|
resetForm();
|
||||||
|
setTreeItems([]);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (isUploading || hasFiles) {
|
||||||
|
ModalRegion.confirm({
|
||||||
|
content: '업로드를 취소하시겠습니까?\n진행 중인 업로드가 중단됩니다.',
|
||||||
|
confirmText: '예',
|
||||||
|
cancelText: '아니오',
|
||||||
|
onConfirm: (close) => {
|
||||||
|
close();
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentUploadingFile = files.find((f) => f.status === 'uploading');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={handleCancel} isKeyboardDismissDisabled>
|
||||||
|
{() => (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleUpload(); }}>
|
||||||
|
<Modal.Header hasCloseButton={false}>업로드 폴더 선택</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<div className="flex flex-col gap-4 py-2 w-140">
|
||||||
|
{/* 타입 선택 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||||
|
타입 선택
|
||||||
|
</label>
|
||||||
|
<Controller
|
||||||
|
name="type"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<Select
|
||||||
|
items={TYPE_OPTIONS}
|
||||||
|
selectedKey={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지역 선택 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||||
|
지역 선택
|
||||||
|
</label>
|
||||||
|
<Controller
|
||||||
|
name="region"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<Select
|
||||||
|
items={regions}
|
||||||
|
selectedKey={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
onChange(v);
|
||||||
|
setValue('folderPath', '');
|
||||||
|
}}
|
||||||
|
placeholder="지역선택"
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폴더 선택 */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0 pt-2">
|
||||||
|
폴더 선택
|
||||||
|
</label>
|
||||||
|
<div className="flex-1 border border-dabeeo-gray-be max-h-60 min-h-40 overflow-y-auto">
|
||||||
|
{isLoadingFolders ? (
|
||||||
|
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : treeItems.length > 0 ? (
|
||||||
|
<Controller
|
||||||
|
name="folderPath"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { onChange } }) => (
|
||||||
|
<Tree
|
||||||
|
ref={treeRef}
|
||||||
|
items={treeItems}
|
||||||
|
selectedKeys={memorizedSelectedKeys}
|
||||||
|
onExpand={(key, item) => {
|
||||||
|
handleExpandFolder(key);
|
||||||
|
onChange(String(key));
|
||||||
|
}}
|
||||||
|
onCollapse={(key) => {
|
||||||
|
handleCollapseFolder(key);
|
||||||
|
}}
|
||||||
|
onSelect={(key) => {
|
||||||
|
if (key) onChange(String(key));
|
||||||
|
}}
|
||||||
|
enableDragAndDrop={false}
|
||||||
|
persistSelectionOnCollapse
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : selectedRegion ? (
|
||||||
|
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||||
|
폴더가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||||
|
지역을 선택해주세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-dabeeo-orange-main ml-24">
|
||||||
|
※ 업로드 대상 폴더를 정확하게 지정하시기 바랍니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 파일명 */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0 pt-2">
|
||||||
|
파일명 <span className="text-dabeeo-red">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex-1 border border-dabeeo-gray-be p-2 min-h-10 max-h-24 overflow-y-auto">
|
||||||
|
{files.length > 0 ? (
|
||||||
|
files.map((file) => (
|
||||||
|
<FileItem key={file.id} file={file} folderPath={selectedFolder} onRemove={removeFile} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-dabeeo-gray-99">파일이 업로드 되면 자동 입력됩니다.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파일 선택 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||||
|
파일 <span className="text-dabeeo-red">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex-1 flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-9 border border-dabeeo-gray-be px-3 flex items-center">
|
||||||
|
<span className="text-sm text-dabeeo-gray-99 truncate">
|
||||||
|
{currentUploadingFile?.fileName || ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
htmlFor="aerial-file-input"
|
||||||
|
className="h-9 px-4 bg-dabeeo-black-34 text-white text-sm font-medium flex items-center justify-center cursor-pointer hover:bg-dabeeo-black-47"
|
||||||
|
>
|
||||||
|
파일선택
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="aerial-file-input"
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".tif,.tiff"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행률 표시 */}
|
||||||
|
{hasFiles && (
|
||||||
|
<div className="flex items-center gap-4 ml-24">
|
||||||
|
<ProgressBar progress={overallProgress} />
|
||||||
|
<span className="text-xs text-dabeeo-black-34 w-10 text-right">{overallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFiles && (
|
||||||
|
<div className="text-xs text-dabeeo-gray-99 ml-24">
|
||||||
|
Loading {formatFileSize(uploadedSize)} / {formatFileSize(totalSize)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button type="button" color="gray" size="large" onClick={handleCancel}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
isDisabled={!hasFiles || isUploading}
|
||||||
|
isPending={isUploading}
|
||||||
|
>
|
||||||
|
업로드
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
164
web-app/app/features/imagery/hooks/useAerialChunkUpload.ts
Normal file
164
web-app/app/features/imagery/hooks/useAerialChunkUpload.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { completeChunkUpload, uploadFileChunk } from '../api/imagery';
|
||||||
|
|
||||||
|
export type FileUploadStatus = 'idle' | 'uploading' | 'completed' | 'error'
|
||||||
|
|
||||||
|
export type UploadingFile = {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
fileName: string;
|
||||||
|
progress: number;
|
||||||
|
status: FileUploadStatus;
|
||||||
|
totalSize: number;
|
||||||
|
uploadedSize: number;
|
||||||
|
uuid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
const MAX_CONCURRENT_UPLOADS = 3
|
||||||
|
|
||||||
|
export const useAerialChunkUpload = () => {
|
||||||
|
const [files, setFiles] = useState<UploadingFile[]>([])
|
||||||
|
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
|
||||||
|
|
||||||
|
const updateFileState = useCallback((id: string, updates: Partial<UploadingFile>) => {
|
||||||
|
setFiles((prev) => prev.map((f) => f.id === id ? { ...f, ...updates } : f))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const uploadSingleFile = useCallback(async (uploadFile: UploadingFile) => {
|
||||||
|
const { id, file } = uploadFile
|
||||||
|
const abortController = new AbortController()
|
||||||
|
abortControllersRef.current.set(id, abortController)
|
||||||
|
|
||||||
|
const { signal } = abortController
|
||||||
|
const fileSize = file.size
|
||||||
|
const fileName = file.name
|
||||||
|
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE)
|
||||||
|
|
||||||
|
updateFileState(id, { status: 'uploading', progress: 0 })
|
||||||
|
|
||||||
|
let uuid = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = chunkIndex * CHUNK_SIZE
|
||||||
|
const end = Math.min(start + CHUNK_SIZE, fileSize)
|
||||||
|
const chunkFile = file.slice(start, end)
|
||||||
|
|
||||||
|
const response = await uploadFileChunk({
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
chunkIndex,
|
||||||
|
chunkTotalIndex: totalChunks - 1,
|
||||||
|
chunkFile,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 번째 청크 응답에서 uuid 저장
|
||||||
|
if (chunkIndex === 0 && response.uuid) {
|
||||||
|
uuid = response.uuid
|
||||||
|
updateFileState(id, { uuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100)
|
||||||
|
const uploadedSize = end
|
||||||
|
|
||||||
|
updateFileState(id, {
|
||||||
|
progress,
|
||||||
|
uploadedSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 청크 업로드 완료 후 completeChunkUpload 호출
|
||||||
|
if (uuid) {
|
||||||
|
await completeChunkUpload(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFileState(id, { status: 'completed', progress: 100 })
|
||||||
|
} catch (error) {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
console.error('Upload error:', error)
|
||||||
|
updateFileState(id, { status: 'error' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abortControllersRef.current.delete(id)
|
||||||
|
}
|
||||||
|
}, [updateFileState])
|
||||||
|
|
||||||
|
const addFiles = useCallback((newFiles: File[]) => {
|
||||||
|
const uploadFiles: UploadingFile[] = newFiles.map((file) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
fileName: file.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'idle' as FileUploadStatus,
|
||||||
|
totalSize: file.size,
|
||||||
|
uploadedSize: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setFiles((prev) => [...prev, ...uploadFiles])
|
||||||
|
return uploadFiles
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startUpload = useCallback(async (filesToUpload?: UploadingFile[]) => {
|
||||||
|
const targetFiles = filesToUpload || files.filter((f) => f.status === 'idle')
|
||||||
|
|
||||||
|
// 병렬 업로드 (최대 MAX_CONCURRENT_UPLOADS개씩)
|
||||||
|
for (let i = 0; i < targetFiles.length; i += MAX_CONCURRENT_UPLOADS) {
|
||||||
|
const batch = targetFiles.slice(i, i + MAX_CONCURRENT_UPLOADS)
|
||||||
|
await Promise.all(batch.map((file) => uploadSingleFile(file)))
|
||||||
|
}
|
||||||
|
}, [files, uploadSingleFile])
|
||||||
|
|
||||||
|
const removeFile = useCallback((id: string) => {
|
||||||
|
// 업로드 중이면 취소
|
||||||
|
const controller = abortControllersRef.current.get(id)
|
||||||
|
if (controller) {
|
||||||
|
controller.abort()
|
||||||
|
abortControllersRef.current.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles((prev) => prev.filter((f) => f.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cancelAllUploads = useCallback(() => {
|
||||||
|
abortControllersRef.current.forEach((controller) => controller.abort())
|
||||||
|
abortControllersRef.current.clear()
|
||||||
|
setFiles([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
cancelAllUploads()
|
||||||
|
}, [cancelAllUploads])
|
||||||
|
|
||||||
|
const isUploading = files.some((f) => f.status === 'uploading')
|
||||||
|
const isAllCompleted = files.length > 0 && files.every((f) => f.status === 'completed')
|
||||||
|
const hasFiles = files.length > 0
|
||||||
|
|
||||||
|
const totalSize = files.reduce((acc, f) => acc + f.totalSize, 0)
|
||||||
|
const uploadedSize = files.reduce((acc, f) => acc + f.uploadedSize, 0)
|
||||||
|
const overallProgress = totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
addFiles,
|
||||||
|
startUpload,
|
||||||
|
removeFile,
|
||||||
|
cancelAllUploads,
|
||||||
|
reset,
|
||||||
|
isUploading,
|
||||||
|
isAllCompleted,
|
||||||
|
hasFiles,
|
||||||
|
totalSize,
|
||||||
|
uploadedSize,
|
||||||
|
overallProgress,
|
||||||
|
}
|
||||||
|
}
|
||||||
46
web-app/app/features/imagery/types/aerial.ts
Normal file
46
web-app/app/features/imagery/types/aerial.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// 목록 아이템
|
||||||
|
export type AerialItem = {
|
||||||
|
mapId: string;
|
||||||
|
fileName: string;
|
||||||
|
region: string;
|
||||||
|
scale: string;
|
||||||
|
capturedDttm: string;
|
||||||
|
fileSize: string;
|
||||||
|
createdDttm: string;
|
||||||
|
createdBy: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상세 정보
|
||||||
|
export type AerialDetail = {
|
||||||
|
uuid: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
region: string;
|
||||||
|
category: string;
|
||||||
|
createdName: string;
|
||||||
|
createdDttm: string;
|
||||||
|
capturedDttm: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 목록 조회 파라미터
|
||||||
|
export type AerialListParams = {
|
||||||
|
dateRangeType: 'capturedDttm' | 'createdDttm';
|
||||||
|
strtDttm: string;
|
||||||
|
endDttm: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 항공영상 데이터 등록
|
||||||
|
export type AerialData = {
|
||||||
|
fileName: string;
|
||||||
|
region: string;
|
||||||
|
scale: string;
|
||||||
|
capturedDttm: string;
|
||||||
|
fileSize: string;
|
||||||
|
status: string;
|
||||||
|
type: '도곽';
|
||||||
|
};
|
||||||
40
web-app/app/features/imagery/types/imageryRegister.ts
Normal file
40
web-app/app/features/imagery/types/imageryRegister.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// 지역 (시/도)
|
||||||
|
export type Region = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폴더
|
||||||
|
export type Folder = {
|
||||||
|
folderNm: string;
|
||||||
|
parentFolderNm: string;
|
||||||
|
parentPath: string;
|
||||||
|
fullPath: string;
|
||||||
|
depth: number;
|
||||||
|
childCnt: number;
|
||||||
|
lastModified: string;
|
||||||
|
isValid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FolderListResponse = {
|
||||||
|
dirPath: string;
|
||||||
|
folders: Folder[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 청크 업로드
|
||||||
|
export type ChunkUploadParams = {
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
chunkIndex: number;
|
||||||
|
chunkTotalIndex: number;
|
||||||
|
chunkFile: Blob;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChunkUploadResponse = {
|
||||||
|
uuid: string;
|
||||||
|
filePath: string;
|
||||||
|
fileName: string;
|
||||||
|
chunkIndex: number;
|
||||||
|
chunkTotalIndex: number;
|
||||||
|
uploadId?: string;
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ export default [
|
|||||||
route('login', './routes/login/page.tsx'),
|
route('login', './routes/login/page.tsx'),
|
||||||
]),
|
]),
|
||||||
layout('./routes/layout.tsx', [
|
layout('./routes/layout.tsx', [
|
||||||
|
index('./routes/page.tsx'),
|
||||||
...prefix('imagery', [
|
...prefix('imagery', [
|
||||||
...prefix('aerial', [
|
...prefix('aerial', [
|
||||||
index('./routes/imagery/aerial/page.tsx'),
|
index('./routes/imagery/aerial/page.tsx'),
|
||||||
|
|||||||
3
web-app/app/routes/change-model/classification/page.tsx
Normal file
3
web-app/app/routes/change-model/classification/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>변화모델 분류모델관리</div>;
|
||||||
|
}
|
||||||
3
web-app/app/routes/change-model/g1/page.tsx
Normal file
3
web-app/app/routes/change-model/g1/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>변화모델 G1 모델관리</div>;
|
||||||
|
}
|
||||||
3
web-app/app/routes/change-model/g2/page.tsx
Normal file
3
web-app/app/routes/change-model/g2/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>변화모델 G2 모델관리</div>;
|
||||||
|
}
|
||||||
3
web-app/app/routes/change-model/g3/page.tsx
Normal file
3
web-app/app/routes/change-model/g3/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>변화모델 G3 모델관리</div>;
|
||||||
|
}
|
||||||
3
web-app/app/routes/change-model/parameter/page.tsx
Normal file
3
web-app/app/routes/change-model/parameter/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>변화모델 파라미터관리</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>공통코드 관리</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>탐지모델 분류모델관리</div>;
|
||||||
|
}
|
||||||
3
web-app/app/routes/detection-model/g1/page.tsx
Normal file
3
web-app/app/routes/detection-model/g1/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>탐지모델 G1 모델관리</div>;
|
||||||
|
}
|
||||||
3
web-app/app/routes/detection-model/g2/page.tsx
Normal file
3
web-app/app/routes/detection-model/g2/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>탐지모델 G2 모델관리</div>;
|
||||||
|
}
|
||||||
3
web-app/app/routes/detection-model/g3/page.tsx
Normal file
3
web-app/app/routes/detection-model/g3/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>탐지모델 G3 모델관리</div>;
|
||||||
|
}
|
||||||
3
web-app/app/routes/detection-model/parameter/page.tsx
Normal file
3
web-app/app/routes/detection-model/parameter/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>탐지모델 파라미터관리</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>하이퍼파라미터 설정</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { AerialList } from '~/features/imagery/components/AerialList';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<div>항공영상관리 목록</div>
|
<AerialList></AerialList>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Route } from './+types/page';
|
||||||
|
|
||||||
|
export default function Page({ params }: Route.ComponentProps) {
|
||||||
|
return <div>추론 상세 id: {params.inferenceId}</div>;
|
||||||
|
}
|
||||||
|
|||||||
3
web-app/app/routes/labeling-management/change/page.tsx
Normal file
3
web-app/app/routes/labeling-management/change/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>변화탐지 라벨링관리</div>;
|
||||||
|
}
|
||||||
3
web-app/app/routes/labeling-management/object/page.tsx
Normal file
3
web-app/app/routes/labeling-management/object/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>객체탐지 라벨링관리</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>라벨링 작업</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>라벨링 검수</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
import { Outlet } from 'react-router';
|
import { Outlet } from 'react-router';
|
||||||
|
|
||||||
|
import { LayoutMenu } from '~/shared/components/menu/LayoutMenu';
|
||||||
|
import { MENU_ITEMS } from '~/shared/constants/menu';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex h-screen w-screen">
|
||||||
기본 레이아웃
|
<aside className="bg-primary-tertiary01 z-10 h-full w-[260px] flex-none flex flex-col shadow-[4px_0_5px_0_rgba(0,0,0,0.1)]">
|
||||||
<Outlet />
|
<div className="flex flex-col items-center bg-white pt-6 pb-4">
|
||||||
|
<div className="flex items-center justify-center pb-7">
|
||||||
|
<span className="text-xl font-bold text-primary">DABEEO</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">사용자 정보</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-primary flex h-12 flex-none items-center px-5 font-bold text-white">
|
||||||
|
변화탐지관리
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<LayoutMenu items={MENU_ITEMS} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="bg-primary-tertiary02 flex flex-1 min-w-0 flex-col">
|
||||||
|
<div className="bg-primary w-full h-4" />
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-6 px-8 pt-6 pb-8">
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>감사 로그</div>;
|
||||||
|
}
|
||||||
|
|||||||
3
web-app/app/routes/log/error/page.tsx
Normal file
3
web-app/app/routes/log/error/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>에러 로그</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>시스템 로그</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Route } from './+types/page';
|
||||||
|
|
||||||
|
export default function Page({ params }: Route.ComponentProps) {
|
||||||
|
return <div>모델 상세 id: {params.modelId}</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>모델 목록</div>;
|
||||||
|
}
|
||||||
|
|||||||
3
web-app/app/routes/object/page.tsx
Normal file
3
web-app/app/routes/object/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>객체탐지</div>;
|
||||||
|
}
|
||||||
5
web-app/app/routes/page.tsx
Normal file
5
web-app/app/routes/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Navigate } from 'react-router';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <Navigate to="/imagery/aerial" replace />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>스케줄 목록</div>;
|
||||||
|
}
|
||||||
|
|||||||
3
web-app/app/routes/terrain/page.tsx
Normal file
3
web-app/app/routes/terrain/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>지형변화탐지</div>;
|
||||||
|
}
|
||||||
BIN
web-app/app/shared/assets/fonts/Pretendard-Bold.subset.woff
Normal file
BIN
web-app/app/shared/assets/fonts/Pretendard-Bold.subset.woff
Normal file
Binary file not shown.
BIN
web-app/app/shared/assets/fonts/Pretendard-Medium.subset.woff
Normal file
BIN
web-app/app/shared/assets/fonts/Pretendard-Medium.subset.woff
Normal file
Binary file not shown.
BIN
web-app/app/shared/assets/fonts/Pretendard-Regular.subset.woff
Normal file
BIN
web-app/app/shared/assets/fonts/Pretendard-Regular.subset.woff
Normal file
Binary file not shown.
BIN
web-app/app/shared/assets/fonts/Pretendard-SemiBold.subset.woff
Normal file
BIN
web-app/app/shared/assets/fonts/Pretendard-SemiBold.subset.woff
Normal file
Binary file not shown.
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import Breadcrumbs from './Breadcrumbs'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Breadcrumbs',
|
||||||
|
component: Breadcrumbs,
|
||||||
|
} satisfies Meta<typeof Breadcrumbs>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
items: ['항공영상 관리', '항공영상 목록'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SingleItem: Story = {
|
||||||
|
args: {
|
||||||
|
items: ['대시보드'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThreeItems: Story = {
|
||||||
|
args: {
|
||||||
|
items: ['설정', '시스템 설정', '일반'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FourItems: Story = {
|
||||||
|
args: {
|
||||||
|
items: ['프로젝트', '프로젝트 A', '탐지 관리', '탐지 결과'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LongNames: Story = {
|
||||||
|
args: {
|
||||||
|
items: ['항공영상 관리 시스템', '상세 항공영상 목록 페이지', '결과 분석'],
|
||||||
|
},
|
||||||
|
}
|
||||||
58
web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx
Normal file
58
web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { Breadcrumb as AriaBreadcrumb, Breadcrumbs as AriaBreadcrumbs } from 'react-aria-components';
|
||||||
|
|
||||||
|
export interface BreadcrumbsProps {
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
const Breadcrumbs: FC<BreadcrumbsProps> = (props) => {
|
||||||
|
const { items } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb">
|
||||||
|
<AriaBreadcrumbs className="flex items-center gap-2.5">
|
||||||
|
<AriaBreadcrumb aria-label="홈">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="19"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 19 18"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.75 14.4545H4.25V7.83333L9.25 4L14.25 7.83333V14.4545H11.75"
|
||||||
|
stroke="#6C7789"
|
||||||
|
strokeLinecap="square"
|
||||||
|
/>
|
||||||
|
<path d="M10.8381 12.9615V10.3633H7.65625V12.9615" stroke="#6C7789" />
|
||||||
|
</svg>
|
||||||
|
</AriaBreadcrumb>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<AriaBreadcrumb
|
||||||
|
className="flex items-center gap-2.5 text-xs text-dabeeo-black-47 data-current:text-dabeeo-black-22"
|
||||||
|
key={`breadcrumb-${index}-${item}`}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="6"
|
||||||
|
height="8"
|
||||||
|
viewBox="0 0 6 8"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2.70722 4.00064L0.25 1.54349L1.4556 0.337891L5.12572 4.00064L1.45572 7.66347L0.250117 6.45786L2.70722 4.00064Z"
|
||||||
|
fill="#6C7789"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{item}
|
||||||
|
</>
|
||||||
|
</AriaBreadcrumb>
|
||||||
|
))}
|
||||||
|
</AriaBreadcrumbs>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Breadcrumbs;
|
||||||
5
web-app/app/shared/components/breadcrumbs/index.ts
Normal file
5
web-app/app/shared/components/breadcrumbs/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { BreadcrumbsProps } from './Breadcrumbs';
|
||||||
|
import InternalBreadcrumbs from './Breadcrumbs';
|
||||||
|
|
||||||
|
export type { BreadcrumbsProps };
|
||||||
|
export const Breadcrumbs = InternalBreadcrumbs;
|
||||||
113
web-app/app/shared/components/button/Button.stories.tsx
Normal file
113
web-app/app/shared/components/button/Button.stories.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { Button } from './Button'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Button',
|
||||||
|
component: Button,
|
||||||
|
argTypes: {
|
||||||
|
color: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['primary', 'light', 'green', 'lightGreen', 'black', 'gray', 'orange', 'navy', 'lightNavy'],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['small', 'medium', 'large'],
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
isPending: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Button>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
children: '버튼',
|
||||||
|
color: 'primary',
|
||||||
|
size: 'medium',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Small: Story = {
|
||||||
|
args: {
|
||||||
|
children: '작은 버튼',
|
||||||
|
size: 'small',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Medium: Story = {
|
||||||
|
args: {
|
||||||
|
children: '중간 버튼',
|
||||||
|
size: 'medium',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Large: Story = {
|
||||||
|
args: {
|
||||||
|
children: '큰 버튼',
|
||||||
|
size: 'large',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Primary',
|
||||||
|
color: 'primary',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Light: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Light',
|
||||||
|
color: 'light',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Green: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Green',
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Navy: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Navy',
|
||||||
|
color: 'navy',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
children: '비활성화',
|
||||||
|
isDisabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
args: {
|
||||||
|
children: '로딩 중',
|
||||||
|
isPending: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllColors: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button color="primary">Primary</Button>
|
||||||
|
<Button color="light">Light</Button>
|
||||||
|
<Button color="green">Green</Button>
|
||||||
|
<Button color="lightGreen">Light Green</Button>
|
||||||
|
<Button color="black">Black</Button>
|
||||||
|
<Button color="gray">Gray</Button>
|
||||||
|
<Button color="orange">Orange</Button>
|
||||||
|
<Button color="navy">Navy</Button>
|
||||||
|
<Button color="lightNavy">Light Navy</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
147
web-app/app/shared/components/button/Button.tsx
Normal file
147
web-app/app/shared/components/button/Button.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import type { ButtonProps as AriaButtonProps } from 'react-aria-components';
|
||||||
|
import { Button as AriaButton, composeRenderProps } from 'react-aria-components';
|
||||||
|
|
||||||
|
import { tv } from 'tailwind-variants';
|
||||||
|
import { LoadingSpinnerIcon } from '../icons/LoadingSpinner';
|
||||||
|
|
||||||
|
export interface ButtonProps extends AriaButtonProps {
|
||||||
|
color?: 'primary' | 'light' | 'green' | 'lightGreen' | 'black' | 'gray' | 'orange' | 'navy' | 'lightNavy';
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
stopPropagation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = tv({
|
||||||
|
base: 'relative inline-flex items-center justify-center gap-2 border cursor-pointer box-border data-focus-visible:ring-2 ring-offset-2',
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: 'h-6 px-3 text-xs font-medium',
|
||||||
|
medium: 'h-9 px-3 text-xs font-medium',
|
||||||
|
large: 'min-w-30 h-13 px-5 text-sm font-semibold',
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
primary: 'text-white bg-primary border-primary disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-primary',
|
||||||
|
light:
|
||||||
|
'text-primary border-primary bg-primary-tertiary02 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-primary',
|
||||||
|
green:
|
||||||
|
'text-white bg-dabeeo-green-main border-dabeeo-green-main disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-dabeeo-green-main',
|
||||||
|
lightGreen:
|
||||||
|
'text-dabeeo-green-main border-dabeeo-green-main bg-dabeeo-green-tertiary02 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-green-main',
|
||||||
|
black:
|
||||||
|
'text-white bg-dabeeo-black-34 border-dabeeo-black-34 disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-dabeeo-black-34',
|
||||||
|
gray: 'text-dabeeo-black-34 bg-dabeeo-gray-eb border-dabeeo-black-34 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-black-34',
|
||||||
|
orange:
|
||||||
|
'text-dabeeo-orange-main bg-dabeeo-orange-tertiary01 border-dabeeo-orange-main disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-orange-main',
|
||||||
|
navy: 'text-white bg-dabeeo-navy-main border-dabeeo-navy-main disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-dabeeo-navy-main',
|
||||||
|
lightNavy:
|
||||||
|
'text-dabeeo-navy-main border-dabeeo-navy-main bg-dabeeo-navy-tertiary02 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-navy-main',
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
true: 'cursor-not-allowed',
|
||||||
|
},
|
||||||
|
isPending: {
|
||||||
|
true: 'text-transparent cursor-progress',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
color: 'primary',
|
||||||
|
isDisabled: false,
|
||||||
|
isPending: false,
|
||||||
|
className:
|
||||||
|
'hover:border-primary-tertiary hover:bg-primary-tertiary active:bg-primary-secondary active:border-primary-secondary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'light',
|
||||||
|
isDisabled: false,
|
||||||
|
isPending: false,
|
||||||
|
className:
|
||||||
|
'hover:bg-primary-tertiary01 active:text-primary-secondary active:bg-primary-tertiary01 active:border-primary-secondary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'green',
|
||||||
|
isDisabled: false,
|
||||||
|
isPending: false,
|
||||||
|
className:
|
||||||
|
'hover:bg-dabeeo-green-tertiary hover:border-dabeeo-green-tertiary active:bg-dabeeo-green-secondary active:border-dabeeo-green-secondary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'lightGreen',
|
||||||
|
isDisabled: false,
|
||||||
|
isPending: false,
|
||||||
|
className:
|
||||||
|
'hover:bg-dabeeo-green-tertiary01 active:text-dabeeo-green-secondary active:bg-dabeeo-green-tertiary01 active:border-dabeeo-green-secondary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'black',
|
||||||
|
isDisabled: false,
|
||||||
|
isPending: false,
|
||||||
|
className: 'hover:bg-dabeeo-black-47 hover:border-dabeeo-black-47 active:bg-dabeeo-black-22 active:border-dabeeo-black-22',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'gray',
|
||||||
|
isDisabled: false,
|
||||||
|
isPending: false,
|
||||||
|
className:
|
||||||
|
'hover:bg-dabeeo-gray-da hover:border-dabeeo-black-47 active:text-dabeeo-black-22 active:bg-dabeeo-gray-da active:border-dabeeo-black-22',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'orange',
|
||||||
|
isDisabled: false,
|
||||||
|
isPending: false,
|
||||||
|
className:
|
||||||
|
'hover:bg-dabeeo-orange-tertiary02 active:text-dabeeo-orange-secondary active:bg-dabeeo-orange-tertiary02 active:border-dabeeo-orange-secondary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'navy',
|
||||||
|
isDisabled: false,
|
||||||
|
isPending: false,
|
||||||
|
className:
|
||||||
|
'hover:border-dabeeo-navy-tertiary hover:bg-dabeeo-navy-tertiary active:bg-dabeeo-navy-secondary active:border-dabeeo-navy-secondary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'lightNavy',
|
||||||
|
isDisabled: false,
|
||||||
|
isPending: false,
|
||||||
|
className:
|
||||||
|
'hover:bg-dabeeo-navy-tertiary01 active:text-dabeeo-navy-secondary active:bg-dabeeo-navy-tertiary01 active:border-dabeeo-navy-secondary',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Button = (props: ButtonProps) => {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
color = 'primary',
|
||||||
|
size = 'medium',
|
||||||
|
children,
|
||||||
|
stopPropagation = false,
|
||||||
|
...restProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaButton
|
||||||
|
className={composeRenderProps(className, (className, renderProps) => {
|
||||||
|
return button({ ...renderProps, color, size, className });
|
||||||
|
})}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (stopPropagation) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
onClick?.(e);
|
||||||
|
}}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{composeRenderProps(children, (children, { isPending }) => (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
{isPending && (
|
||||||
|
<span aria-hidden className="flex absolute inset-0 justify-center items-center">
|
||||||
|
<LoadingSpinnerIcon className="w-4 h-4 text-white animate-spin" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</AriaButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
62
web-app/app/shared/components/calendar/Calendar.stories.tsx
Normal file
62
web-app/app/shared/components/calendar/Calendar.stories.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Calendar } from './Calendar'
|
||||||
|
import { RangeCalendar } from './RangeCalendar'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Calendar',
|
||||||
|
component: Calendar,
|
||||||
|
argTypes: {
|
||||||
|
isDisabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Calendar>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithSelectedDate: Story = {
|
||||||
|
render: function Render() {
|
||||||
|
const [date, setDate] = useState<Date | null>(new Date())
|
||||||
|
return <Calendar value={date} onChange={setDate} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithMinMaxDate: Story = {
|
||||||
|
render: function Render() {
|
||||||
|
const [date, setDate] = useState<Date | null>(new Date())
|
||||||
|
const minDate = new Date()
|
||||||
|
minDate.setDate(minDate.getDate() - 7)
|
||||||
|
const maxDate = new Date()
|
||||||
|
maxDate.setDate(maxDate.getDate() + 7)
|
||||||
|
return <Calendar value={date} onChange={setDate} minValue={minDate} maxValue={maxDate} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
isDisabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Range: StoryObj<typeof RangeCalendar> = {
|
||||||
|
render: function Render() {
|
||||||
|
const [range, setRange] = useState<{ start: Date; end: Date } | null>(null)
|
||||||
|
return <RangeCalendar value={range} onChange={setRange} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RangeWithSelectedDates: StoryObj<typeof RangeCalendar> = {
|
||||||
|
render: function Render() {
|
||||||
|
const start = new Date()
|
||||||
|
const end = new Date()
|
||||||
|
end.setDate(end.getDate() + 7)
|
||||||
|
const [range, setRange] = useState<{ start: Date; end: Date } | null>({ start, end })
|
||||||
|
return <RangeCalendar value={range} onChange={setRange} />
|
||||||
|
},
|
||||||
|
}
|
||||||
181
web-app/app/shared/components/calendar/Calendar.tsx
Normal file
181
web-app/app/shared/components/calendar/Calendar.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import {
|
||||||
|
Button as AriaButton,
|
||||||
|
Calendar as AriaCalendar,
|
||||||
|
CalendarGridHeader as AriaCalendarGridHeader,
|
||||||
|
CalendarCell,
|
||||||
|
CalendarGrid,
|
||||||
|
CalendarGridBody,
|
||||||
|
CalendarHeaderCell,
|
||||||
|
Heading,
|
||||||
|
} from 'react-aria-components';
|
||||||
|
|
||||||
|
import { getLocalTimeZone, today } from '@internationalized/date';
|
||||||
|
import { tv } from 'tailwind-variants';
|
||||||
|
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from '../icons';
|
||||||
|
|
||||||
|
import { calendarDateToDate, dateToCalendarDate } from './utils';
|
||||||
|
|
||||||
|
const cellStyles = tv({
|
||||||
|
base: 'flex h-[30px] w-[30px] cursor-default items-center justify-center rounded-full text-xs outline-none',
|
||||||
|
variants: {
|
||||||
|
isSelected: {
|
||||||
|
true: 'bg-primary font-bold text-white',
|
||||||
|
false: '',
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
true: 'cursor-default text-dabeeo-gray-be',
|
||||||
|
},
|
||||||
|
isUnavailable: {
|
||||||
|
true: 'cursor-default text-dabeeo-gray-be line-through',
|
||||||
|
},
|
||||||
|
isOutsideMonth: {
|
||||||
|
true: 'text-dabeeo-gray-be',
|
||||||
|
},
|
||||||
|
isSunday: {
|
||||||
|
true: 'text-[#e48686]',
|
||||||
|
},
|
||||||
|
isSaturday: {
|
||||||
|
true: 'text-[#7b8cc8]',
|
||||||
|
},
|
||||||
|
isToday: {
|
||||||
|
true: 'font-bold text-primary',
|
||||||
|
},
|
||||||
|
isHovered: {
|
||||||
|
true: 'bg-primary-tertiary01',
|
||||||
|
},
|
||||||
|
isFocusVisible: {
|
||||||
|
true: 'ring-2 ring-offset-2 ring-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
isSelected: true,
|
||||||
|
isSunday: true,
|
||||||
|
className: 'text-white',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isSelected: true,
|
||||||
|
isSaturday: true,
|
||||||
|
className: 'text-white',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isSelected: true,
|
||||||
|
isToday: true,
|
||||||
|
className: 'text-white',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isSelected: true,
|
||||||
|
isHovered: true,
|
||||||
|
className: 'bg-primary',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface CalendarProps {
|
||||||
|
value?: Date | null;
|
||||||
|
defaultValue?: Date | null;
|
||||||
|
onChange?: (date: Date | null) => void;
|
||||||
|
maxValue?: Date | null;
|
||||||
|
minValue?: Date | null;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Calendar({ value, defaultValue, onChange, maxValue, minValue, isDisabled, className }: CalendarProps) {
|
||||||
|
const todayDate = today(getLocalTimeZone());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaCalendar
|
||||||
|
aria-label="날짜 선택"
|
||||||
|
value={value !== undefined ? dateToCalendarDate(value ?? null) : undefined}
|
||||||
|
defaultValue={defaultValue !== undefined ? dateToCalendarDate(defaultValue ?? null) : undefined}
|
||||||
|
onChange={(val) => onChange?.(calendarDateToDate(val))}
|
||||||
|
maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
|
||||||
|
minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
firstDayOfWeek="sun"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<CalendarHeader />
|
||||||
|
<CalendarGrid className="border-spacing-0">
|
||||||
|
<CalendarGridHeader />
|
||||||
|
<CalendarGridBody>
|
||||||
|
{(date) => {
|
||||||
|
const dayOfWeek = date.toDate('UTC').getDay();
|
||||||
|
const isSunday = dayOfWeek === 0;
|
||||||
|
const isSaturday = dayOfWeek === 6;
|
||||||
|
const isToday = date.compare(todayDate) === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CalendarCell
|
||||||
|
date={date}
|
||||||
|
className={({
|
||||||
|
isSelected,
|
||||||
|
isDisabled: isCellDisabled,
|
||||||
|
isUnavailable,
|
||||||
|
isOutsideMonth,
|
||||||
|
isHovered,
|
||||||
|
isFocusVisible,
|
||||||
|
}) =>
|
||||||
|
cellStyles({
|
||||||
|
isSelected,
|
||||||
|
isDisabled: isCellDisabled,
|
||||||
|
isUnavailable,
|
||||||
|
isOutsideMonth,
|
||||||
|
isSunday: !isSelected && !isDisabled && !isOutsideMonth ? isSunday : false,
|
||||||
|
isSaturday: !isSelected && !isDisabled && !isOutsideMonth ? isSaturday : false,
|
||||||
|
isToday: !isSelected ? isToday : false,
|
||||||
|
isHovered: !isSelected ? isHovered : false,
|
||||||
|
isFocusVisible,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</CalendarGridBody>
|
||||||
|
</CalendarGrid>
|
||||||
|
</AriaCalendar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navButton = tv({
|
||||||
|
base: 'flex h-7 w-7 items-center justify-center rounded-sm border-none bg-transparent outline-none',
|
||||||
|
variants: {
|
||||||
|
isDisabled: {
|
||||||
|
true: 'cursor-default text-dabeeo-gray-be',
|
||||||
|
false: 'cursor-pointer text-dabeeo-black-34 hover:bg-dabeeo-gray-eb',
|
||||||
|
},
|
||||||
|
isFocusVisible: {
|
||||||
|
true: 'ring-2 ring-offset-2 ring-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
isDisabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CalendarHeader() {
|
||||||
|
return (
|
||||||
|
<header className="flex items-center gap-1 pb-3">
|
||||||
|
<AriaButton slot="previous" className={(renderProps) => navButton(renderProps)}>
|
||||||
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
|
</AriaButton>
|
||||||
|
<Heading className="mx-2 flex-1 text-center text-sm font-semibold text-dabeeo-black-34" />
|
||||||
|
<AriaButton slot="next" className={(renderProps) => navButton(renderProps)}>
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</AriaButton>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarGridHeader() {
|
||||||
|
return (
|
||||||
|
<AriaCalendarGridHeader>
|
||||||
|
{(day) => (
|
||||||
|
<CalendarHeaderCell className="h-[30px] w-[30px] text-xs font-semibold text-dabeeo-gray-99 first:text-[#e48686] last:text-[#7b8cc8]">
|
||||||
|
{day}
|
||||||
|
</CalendarHeaderCell>
|
||||||
|
)}
|
||||||
|
</AriaCalendarGridHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
web-app/app/shared/components/calendar/RangeCalendar.tsx
Normal file
195
web-app/app/shared/components/calendar/RangeCalendar.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import {
|
||||||
|
RangeCalendar as AriaRangeCalendar,
|
||||||
|
CalendarCell,
|
||||||
|
CalendarGrid,
|
||||||
|
CalendarGridBody,
|
||||||
|
type DateValue,
|
||||||
|
} from 'react-aria-components';
|
||||||
|
|
||||||
|
import { getLocalTimeZone, today } from '@internationalized/date';
|
||||||
|
import { tv } from 'tailwind-variants';
|
||||||
|
|
||||||
|
import { CalendarGridHeader, CalendarHeader } from './Calendar';
|
||||||
|
import { calendarDateToDate, dateToCalendarDate } from './utils';
|
||||||
|
|
||||||
|
const rangeCell = tv({
|
||||||
|
base: 'flex h-full w-full items-center justify-center rounded-full text-xs',
|
||||||
|
variants: {
|
||||||
|
selectionState: {
|
||||||
|
none: '',
|
||||||
|
middle: '',
|
||||||
|
cap: 'bg-primary font-bold text-white',
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
true: 'text-dabeeo-gray-be',
|
||||||
|
},
|
||||||
|
isOutsideMonth: {
|
||||||
|
true: 'text-dabeeo-gray-be',
|
||||||
|
},
|
||||||
|
isSunday: {
|
||||||
|
true: 'text-[#e48686]',
|
||||||
|
},
|
||||||
|
isSaturday: {
|
||||||
|
true: 'text-[#7b8cc8]',
|
||||||
|
},
|
||||||
|
isToday: {
|
||||||
|
true: 'font-bold text-primary',
|
||||||
|
},
|
||||||
|
isHovered: {
|
||||||
|
true: '',
|
||||||
|
},
|
||||||
|
isFocusVisible: {
|
||||||
|
true: 'ring-2 ring-offset-2 ring-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
selectionState: 'cap',
|
||||||
|
isSunday: true,
|
||||||
|
className: 'text-white',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectionState: 'cap',
|
||||||
|
isSaturday: true,
|
||||||
|
className: 'text-white',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectionState: 'cap',
|
||||||
|
isToday: true,
|
||||||
|
className: 'text-white',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectionState: 'none',
|
||||||
|
isHovered: true,
|
||||||
|
className: 'bg-primary-tertiary01 rounded-full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectionState: 'middle',
|
||||||
|
isHovered: true,
|
||||||
|
className: 'bg-primary-tertiary01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RangeCalendarProps {
|
||||||
|
value?: { start: Date; end: Date } | null;
|
||||||
|
defaultValue?: { start: Date; end: Date } | null;
|
||||||
|
onChange?: (value: { start: Date; end: Date } | null) => void;
|
||||||
|
maxValue?: Date | null;
|
||||||
|
minValue?: Date | null;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RangeCalendar({
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
maxValue,
|
||||||
|
minValue,
|
||||||
|
isDisabled,
|
||||||
|
className,
|
||||||
|
}: RangeCalendarProps) {
|
||||||
|
const todayDate = today(getLocalTimeZone());
|
||||||
|
|
||||||
|
const ariaValue =
|
||||||
|
value !== undefined
|
||||||
|
? value
|
||||||
|
? { start: dateToCalendarDate(value.start)!, end: dateToCalendarDate(value.end)! }
|
||||||
|
: null
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const ariaDefaultValue =
|
||||||
|
defaultValue !== undefined
|
||||||
|
? defaultValue
|
||||||
|
? { start: dateToCalendarDate(defaultValue.start)!, end: dateToCalendarDate(defaultValue.end)! }
|
||||||
|
: null
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaRangeCalendar<DateValue>
|
||||||
|
aria-label="날짜 범위 선택"
|
||||||
|
value={ariaValue}
|
||||||
|
defaultValue={ariaDefaultValue}
|
||||||
|
onChange={(range) => {
|
||||||
|
if (!range) {
|
||||||
|
onChange?.(null);
|
||||||
|
} else {
|
||||||
|
onChange?.({
|
||||||
|
start: calendarDateToDate(range.start)!,
|
||||||
|
end: calendarDateToDate(range.end)!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
|
||||||
|
minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
firstDayOfWeek="sun"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<CalendarHeader />
|
||||||
|
<CalendarGrid className="border-spacing-0 [&_td]:px-0 [&_td]:py-px">
|
||||||
|
<CalendarGridHeader />
|
||||||
|
<CalendarGridBody>
|
||||||
|
{(date) => {
|
||||||
|
const dayOfWeek = date.toDate('UTC').getDay();
|
||||||
|
const isSunday = dayOfWeek === 0;
|
||||||
|
const isSaturday = dayOfWeek === 6;
|
||||||
|
const isToday = date.compare(todayDate) === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CalendarCell
|
||||||
|
date={date}
|
||||||
|
className={({ isSelected, isSelectionStart, isSelectionEnd }) => {
|
||||||
|
const classes = ['group h-[30px] w-[30px] cursor-default text-sm outline-none'];
|
||||||
|
if (isSelected) {
|
||||||
|
classes.push('bg-primary/10');
|
||||||
|
if (isSelectionStart) classes.push('rounded-s-full');
|
||||||
|
if (isSelectionEnd) classes.push('rounded-e-full');
|
||||||
|
}
|
||||||
|
return classes.join(' ');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({
|
||||||
|
formattedDate,
|
||||||
|
isSelected,
|
||||||
|
isSelectionStart,
|
||||||
|
isSelectionEnd,
|
||||||
|
isDisabled: isCellDisabled,
|
||||||
|
isOutsideMonth,
|
||||||
|
isHovered,
|
||||||
|
isFocusVisible,
|
||||||
|
}) => {
|
||||||
|
const selectionState =
|
||||||
|
isSelected && (isSelectionStart || isSelectionEnd)
|
||||||
|
? ('cap' as const)
|
||||||
|
: isSelected
|
||||||
|
? ('middle' as const)
|
||||||
|
: ('none' as const);
|
||||||
|
const showDayColor = selectionState !== 'cap' && !isCellDisabled && !isOutsideMonth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={rangeCell({
|
||||||
|
selectionState,
|
||||||
|
isDisabled: isCellDisabled,
|
||||||
|
isOutsideMonth,
|
||||||
|
isSunday: showDayColor ? isSunday : false,
|
||||||
|
isSaturday: showDayColor ? isSaturday : false,
|
||||||
|
isToday: selectionState !== 'cap' ? isToday : false,
|
||||||
|
isHovered: !isSelected ? isHovered : false,
|
||||||
|
isFocusVisible,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formattedDate}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</CalendarCell>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</CalendarGridBody>
|
||||||
|
</CalendarGrid>
|
||||||
|
</AriaRangeCalendar>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
web-app/app/shared/components/calendar/index.ts
Normal file
4
web-app/app/shared/components/calendar/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { Calendar } from './Calendar';
|
||||||
|
export type { CalendarProps } from './Calendar';
|
||||||
|
export { RangeCalendar } from './RangeCalendar';
|
||||||
|
export type { RangeCalendarProps } from './RangeCalendar';
|
||||||
13
web-app/app/shared/components/calendar/utils.ts
Normal file
13
web-app/app/shared/components/calendar/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { CalendarDate, type DateValue, fromDate, toCalendarDate } from '@internationalized/date';
|
||||||
|
|
||||||
|
const TIMEZONE = 'Asia/Seoul';
|
||||||
|
|
||||||
|
export function dateToCalendarDate(date: Date | null): CalendarDate | null {
|
||||||
|
if (!date) return null;
|
||||||
|
return toCalendarDate(fromDate(date, TIMEZONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calendarDateToDate(value: DateValue | null): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
return value.toDate(TIMEZONE);
|
||||||
|
}
|
||||||
115
web-app/app/shared/components/checkbox/Checkbox.stories.tsx
Normal file
115
web-app/app/shared/components/checkbox/Checkbox.stories.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Checkbox } from './Checkbox'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Checkbox',
|
||||||
|
component: Checkbox,
|
||||||
|
argTypes: {
|
||||||
|
isDisabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
isIndeterminate: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Checkbox>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
children: '동의합니다',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Checked: Story = {
|
||||||
|
render: function Render() {
|
||||||
|
const [isSelected, setIsSelected] = useState(true)
|
||||||
|
return (
|
||||||
|
<Checkbox isSelected={isSelected} onChange={setIsSelected}>
|
||||||
|
선택됨
|
||||||
|
</Checkbox>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Unchecked: Story = {
|
||||||
|
render: function Render() {
|
||||||
|
const [isSelected, setIsSelected] = useState(false)
|
||||||
|
return (
|
||||||
|
<Checkbox isSelected={isSelected} onChange={setIsSelected}>
|
||||||
|
선택 안됨
|
||||||
|
</Checkbox>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Indeterminate: Story = {
|
||||||
|
args: {
|
||||||
|
isIndeterminate: true,
|
||||||
|
children: '일부 선택됨',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
isDisabled: true,
|
||||||
|
children: '비활성화',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DisabledChecked: Story = {
|
||||||
|
args: {
|
||||||
|
isDisabled: true,
|
||||||
|
isSelected: true,
|
||||||
|
children: '비활성화 (선택됨)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithoutLabel: Story = {
|
||||||
|
args: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MultipleCheckboxes: Story = {
|
||||||
|
render: function Render() {
|
||||||
|
const [checkedItems, setCheckedItems] = useState<Record<string, boolean>>({
|
||||||
|
option1: false,
|
||||||
|
option2: true,
|
||||||
|
option3: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleChange = (key: string) => (isSelected: boolean) => {
|
||||||
|
setCheckedItems((prev) => ({ ...prev, [key]: isSelected }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Checkbox isSelected={checkedItems.option1} onChange={handleChange('option1')}>
|
||||||
|
옵션 1
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox isSelected={checkedItems.option2} onChange={handleChange('option2')}>
|
||||||
|
옵션 2
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox isSelected={checkedItems.option3} onChange={handleChange('option3')}>
|
||||||
|
옵션 3
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllStates: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Checkbox>기본</Checkbox>
|
||||||
|
<Checkbox isSelected>선택됨</Checkbox>
|
||||||
|
<Checkbox isIndeterminate>일부 선택</Checkbox>
|
||||||
|
<Checkbox isDisabled>비활성화</Checkbox>
|
||||||
|
<Checkbox isDisabled isSelected>
|
||||||
|
비활성화 (선택됨)
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
66
web-app/app/shared/components/checkbox/Checkbox.tsx
Normal file
66
web-app/app/shared/components/checkbox/Checkbox.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { CheckboxProps as AriaCheckboxProps } from 'react-aria-components';
|
||||||
|
import { Checkbox as AriaCheckbox, composeRenderProps } from 'react-aria-components';
|
||||||
|
|
||||||
|
import { tv } from 'tailwind-variants';
|
||||||
|
|
||||||
|
const checkbox = tv({
|
||||||
|
base: 'inline-flex items-center gap-2 text-sm font-medium leading-7',
|
||||||
|
variants: {
|
||||||
|
isDisabled: {
|
||||||
|
true: 'text-dabeeo-gray-be',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const box = tv({
|
||||||
|
base: 'w-4 h-4 box-border shrink-0 flex items-center justify-center text-white border border-dabeeo-gray-be transition',
|
||||||
|
variants: {
|
||||||
|
isSelected: {
|
||||||
|
true: 'bg-primary border-primary',
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
true: 'text-dabeeo-gray-99 bg-dabeeo-gray-eb border-dabeeo-gray-be cursor-not-allowed',
|
||||||
|
},
|
||||||
|
isFocusVisible: {
|
||||||
|
true: 'ring-2 ring-offset-2 ring-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Checkbox = (props: AriaCheckboxProps) => {
|
||||||
|
const { className, children, ...restProps } = props;
|
||||||
|
return (
|
||||||
|
<AriaCheckbox
|
||||||
|
className={composeRenderProps(className, (className, renderProps) => checkbox({ ...renderProps, className }))}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{composeRenderProps(children, (children, { isSelected, isIndeterminate, ...renderProps }) => (
|
||||||
|
<>
|
||||||
|
<div className={box({ isSelected: isSelected || isIndeterminate, ...renderProps })}>
|
||||||
|
{isIndeterminate ? (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
) : isSelected ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 7.00893L7.85714 10.625L13 5" stroke="currentColor" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</AriaCheckbox>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { DatePicker } from './DatePicker'
|
||||||
|
import { DateRangePicker } from './DateRangePicker'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/DatePicker',
|
||||||
|
component: DatePicker,
|
||||||
|
args: {
|
||||||
|
value: null,
|
||||||
|
onChange: () => {},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
isDisabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof DatePicker>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: function Render() {
|
||||||
|
const [date, setDate] = useState<Date | null>(null)
|
||||||
|
return <DatePicker value={date} onChange={setDate} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithSelectedDate: Story = {
|
||||||
|
render: function Render() {
|
||||||
|
const [date, setDate] = useState<Date | null>(new Date())
|
||||||
|
return <DatePicker value={date} onChange={setDate} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithMinMaxDate: Story = {
|
||||||
|
render: function Render() {
|
||||||
|
const [date, setDate] = useState<Date | null>(new Date())
|
||||||
|
const minDate = new Date()
|
||||||
|
minDate.setDate(minDate.getDate() - 7)
|
||||||
|
const maxDate = new Date()
|
||||||
|
maxDate.setDate(maxDate.getDate() + 7)
|
||||||
|
return <DatePicker value={date} onChange={setDate} minValue={minDate} maxValue={maxDate} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: function Render() {
|
||||||
|
const [date, setDate] = useState<Date | null>(new Date())
|
||||||
|
return <DatePicker value={date} onChange={setDate} isDisabled />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Range: StoryObj<typeof DateRangePicker> = {
|
||||||
|
render: function Render() {
|
||||||
|
const [range, setRange] = useState<{ start: Date; end: Date } | null>(null)
|
||||||
|
return <DateRangePicker value={range} onChange={setRange} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RangeWithSelectedDates: StoryObj<typeof DateRangePicker> = {
|
||||||
|
render: function Render() {
|
||||||
|
const start = new Date()
|
||||||
|
const end = new Date()
|
||||||
|
end.setDate(end.getDate() + 7)
|
||||||
|
const [range, setRange] = useState<{ start: Date; end: Date } | null>({ start, end })
|
||||||
|
return <DateRangePicker value={range} onChange={setRange} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RangeDisabled: StoryObj<typeof DateRangePicker> = {
|
||||||
|
render: function Render() {
|
||||||
|
const start = new Date()
|
||||||
|
const end = new Date()
|
||||||
|
end.setDate(end.getDate() + 7)
|
||||||
|
const [range, setRange] = useState<{ start: Date; end: Date } | null>({ start, end })
|
||||||
|
return <DateRangePicker value={range} onChange={setRange} isDisabled />
|
||||||
|
},
|
||||||
|
}
|
||||||
79
web-app/app/shared/components/datePicker/DatePicker.tsx
Normal file
79
web-app/app/shared/components/datePicker/DatePicker.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { DatePicker as AriaDatePicker, Button, Dialog, Group, OverlayArrow, Popover } from 'react-aria-components';
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { tv } from 'tailwind-variants';
|
||||||
|
|
||||||
|
import { Calendar } from '../calendar';
|
||||||
|
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
||||||
|
import { CalendarIcon } from '../icons';
|
||||||
|
|
||||||
|
const trigger = tv({
|
||||||
|
base: 'flex h-9 w-[156px] cursor-pointer items-center border border-dabeeo-gray-be bg-white text-left outline-none transition',
|
||||||
|
variants: {
|
||||||
|
isHovered: {
|
||||||
|
true: 'border-dabeeo-black-34',
|
||||||
|
},
|
||||||
|
isFocused: {
|
||||||
|
true: 'border-dabeeo-black-34',
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
true: 'cursor-default border-dabeeo-gray-99 bg-dabeeo-gray-eb',
|
||||||
|
},
|
||||||
|
isFocusVisible: {
|
||||||
|
true: 'ring-2 ring-offset-2 ring-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DatePickerProps = {
|
||||||
|
value: Date | null;
|
||||||
|
onChange: (date: Date | null) => void;
|
||||||
|
maxValue?: Date | null;
|
||||||
|
minValue?: Date | null;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DatePicker({ value, onChange, maxValue, minValue, isDisabled, className, name }: DatePickerProps) {
|
||||||
|
return (
|
||||||
|
<AriaDatePicker
|
||||||
|
aria-label="날짜 선택"
|
||||||
|
value={dateToCalendarDate(value)}
|
||||||
|
onChange={(val) => onChange(calendarDateToDate(val))}
|
||||||
|
maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
|
||||||
|
minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
granularity="day"
|
||||||
|
firstDayOfWeek="sun"
|
||||||
|
className={className}
|
||||||
|
name={name}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<Button className={(renderProps) => trigger(renderProps)}>
|
||||||
|
<span className={`flex-1 px-3 text-sm ${value ? 'text-dabeeo-black-34' : 'text-dabeeo-gray-99'}`}>
|
||||||
|
{value ? dayjs(value).format('YYYY.MM.DD') : 'YYYY.MM.DD'}
|
||||||
|
</span>
|
||||||
|
<span className="flex h-full w-8 shrink-0 items-center justify-center text-dabeeo-gray-99">
|
||||||
|
<CalendarIcon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Popover className="bg-white border border-dabeeo-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
|
||||||
|
<OverlayArrow className="group/arrow">
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="8"
|
||||||
|
viewBox="0 0 12 8"
|
||||||
|
className="block fill-white stroke-dabeeo-gray-be group-data-[placement=bottom]/arrow:rotate-180"
|
||||||
|
>
|
||||||
|
<path d="M0 0 L6 8 L12 0" />
|
||||||
|
</svg>
|
||||||
|
</OverlayArrow>
|
||||||
|
<Dialog className="outline-none">
|
||||||
|
<Calendar />
|
||||||
|
</Dialog>
|
||||||
|
</Popover>
|
||||||
|
</AriaDatePicker>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
web-app/app/shared/components/datePicker/DateRangePicker.tsx
Normal file
113
web-app/app/shared/components/datePicker/DateRangePicker.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
DateRangePicker as AriaDateRangePicker,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Group,
|
||||||
|
OverlayArrow,
|
||||||
|
Popover,
|
||||||
|
} from 'react-aria-components';
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { tv } from 'tailwind-variants';
|
||||||
|
|
||||||
|
import { CalendarIcon } from '../icons';
|
||||||
|
|
||||||
|
import { RangeCalendar } from '../calendar/RangeCalendar';
|
||||||
|
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
||||||
|
|
||||||
|
const trigger = tv({
|
||||||
|
base: 'flex h-9 w-[260px] cursor-pointer items-center border border-dabeeo-gray-be bg-white text-left outline-none transition',
|
||||||
|
variants: {
|
||||||
|
isHovered: {
|
||||||
|
true: 'border-dabeeo-black-34',
|
||||||
|
},
|
||||||
|
isFocused: {
|
||||||
|
true: 'border-dabeeo-black-34',
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
true: 'cursor-default border-dabeeo-gray-99 bg-dabeeo-gray-eb',
|
||||||
|
},
|
||||||
|
isFocusVisible: {
|
||||||
|
true: 'ring-2 ring-offset-2 ring-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DateRangePickerProps = {
|
||||||
|
value: { start: Date; end: Date } | null;
|
||||||
|
onChange: (value: { start: Date; end: Date } | null) => void;
|
||||||
|
maxValue?: Date | null;
|
||||||
|
minValue?: Date | null;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
startName?: string;
|
||||||
|
endName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DateRangePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
maxValue,
|
||||||
|
minValue,
|
||||||
|
isDisabled,
|
||||||
|
className,
|
||||||
|
startName,
|
||||||
|
endName,
|
||||||
|
}: DateRangePickerProps) {
|
||||||
|
const ariaValue = value ? { start: dateToCalendarDate(value.start)!, end: dateToCalendarDate(value.end)! } : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaDateRangePicker
|
||||||
|
aria-label="날짜 범위 선택"
|
||||||
|
value={ariaValue}
|
||||||
|
onChange={(range) => {
|
||||||
|
if (!range) {
|
||||||
|
onChange(null);
|
||||||
|
} else {
|
||||||
|
onChange({
|
||||||
|
start: calendarDateToDate(range.start)!,
|
||||||
|
end: calendarDateToDate(range.end)!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
|
||||||
|
minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
granularity="day"
|
||||||
|
firstDayOfWeek="sun"
|
||||||
|
className={className}
|
||||||
|
startName={startName}
|
||||||
|
endName={endName}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<Button className={(renderProps) => trigger(renderProps)}>
|
||||||
|
<span className={`tabular-nums px-3 text-sm ${value?.start ? 'text-dabeeo-black-34' : 'text-dabeeo-gray-99'}`}>
|
||||||
|
{value?.start ? dayjs(value.start).format('YYYY. MM. DD') : 'YYYY.MM.DD'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-dabeeo-gray-99">~</span>
|
||||||
|
<span className={`tabular-nums flex-1 px-3 text-sm ${value?.end ? 'text-dabeeo-black-34' : 'text-dabeeo-gray-99'}`}>
|
||||||
|
{value?.end ? dayjs(value.end).format('YYYY. MM. DD') : 'YYYY.MM.DD'}
|
||||||
|
</span>
|
||||||
|
<span className="flex h-full w-8 shrink-0 items-center justify-center text-dabeeo-gray-99">
|
||||||
|
<CalendarIcon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Popover className="bg-white border border-dabeeo-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
|
||||||
|
<OverlayArrow className="group/arrow">
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="8"
|
||||||
|
viewBox="0 0 12 8"
|
||||||
|
className="block fill-white stroke-dabeeo-gray-be group-data-[placement=bottom]/arrow:rotate-180"
|
||||||
|
>
|
||||||
|
<path d="M0 0 L6 8 L12 0" />
|
||||||
|
</svg>
|
||||||
|
</OverlayArrow>
|
||||||
|
<Dialog className="outline-none">
|
||||||
|
<RangeCalendar />
|
||||||
|
</Dialog>
|
||||||
|
</Popover>
|
||||||
|
</AriaDateRangePicker>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user