Compare commits

...

34 Commits

Author SHA1 Message Date
861fac88f7 Merge pull request 'feature/lucy-components' (#10) from feature/lucy-components into develop
Reviewed-on: #10
2026-04-14 10:40:14 +09:00
8d6bff88d6 feat: storybook 추가 2026-04-14 10:38:36 +09:00
52f8c30b2e feat: 공통 컴포넌트 추가 생성 2026-04-13 15:27:25 +09:00
01c3590682 Merge pull request 'feat: storybook 생성' (#9) from feature/lucy-storybook into develop
Reviewed-on: #9
2026-04-13 14:15:28 +09:00
01bfc751f2 feat: storybook 생성 2026-04-13 14:15:00 +09:00
b390777af0 Merge pull request 'feature/lucy-aerial-register' (#8) from feature/lucy-aerial-register into develop
Reviewed-on: #8
2026-04-13 10:03:58 +09:00
8b33161284 feat: 항공영상 업로드 모달 2026-04-10 12:24:10 +09:00
35f6023f5a feat: 컴포넌트 추가, 스타일 수정 2026-04-10 12:23:48 +09:00
a836333512 chore: lint 수정, package 추가 2026-04-10 12:23:07 +09:00
57a6e4b51e Merge pull request 'feat: 항공영상관리 api 함수 임시 구현' (#7) from feature/lucy-aerial into develop
Reviewed-on: #7
2026-04-10 08:57:20 +09:00
5c4df5a19a Merge branch 'develop' into feature/lucy-aerial 2026-04-10 08:51:27 +09:00
ec281bf354 Merge remote-tracking branch 'origin/develop' into develop 2026-04-09 17:43:46 +09:00
9cd5ff309f 샘플코드 추가 2026-04-09 17:43:36 +09:00
839901d663 feat: 항공영상관리 2026-04-09 17:03:06 +09:00
76dab1c6a9 feat: 항공영상관리 api 함수 임시 구현 2026-04-09 16:05:49 +09:00
df7c877319 Merge pull request 'feature/lucy-component' (#6) from feature/lucy-component into develop
Reviewed-on: #6
2026-04-09 16:04:46 +09:00
46dc4fdf73 feat: Table, Section 컴포넌트 추가 2026-04-09 11:46:41 +09:00
34d5a56a80 feat: button, calendar, datePicker, input, pagination, icons 추가 2026-04-09 11:02:18 +09:00
6da730f014 fix: 메뉴 상수 수정, 레이아웃 스타일 수정 2026-04-09 10:52:46 +09:00
9b9fb24028 .gitkeep 추가 2026-04-09 10:15:40 +09:00
db109dfe9a readme 수정 2026-04-09 09:22:33 +09:00
42cd85f8e5 패키지 구조 수정 및 추가 2026-04-08 17:13:45 +09:00
2d0834472d Merge pull request 'feat: 기본 레이아웃 설정, 스타일 설정, 메뉴 세팅 (ui는 kamco 프로젝트 기반)' (#5) from feature/lucy-menu into develop
Reviewed-on: #5
2026-04-08 16:58:38 +09:00
83ad885641 feat: 기본 레이아웃 설정, 스타일 설정, 메뉴 세팅 (ui는 kamco 프로젝트 기반) 2026-04-08 16:50:15 +09:00
68dccc10aa 패키지 구조 수정 및 추가 2026-04-08 16:40:27 +09:00
8a24288f1e Merge remote-tracking branch 'origin/develop' into develop 2026-04-08 16:30:40 +09:00
49447c9065 패키지 구조 수정 및 추가 2026-04-08 16:30:28 +09:00
3f5e1fe6d1 Merge pull request '영상데이터관리 라우트 수정' (#4) from feature/lucy-imagery into develop
Reviewed-on: #4
2026-04-08 15:56:41 +09:00
50cf7445b7 Merge branch 'develop' into feature/lucy-imagery 2026-04-08 15:56:05 +09:00
e8420e262c feat: 영상관리 라우트 수정 2026-04-08 15:55:24 +09:00
32edd13cb3 프론트엔드 패키지 추가 (#3)
Reviewed-on: #3
Co-authored-by: Jinseok (심진석) <jinseok.sim@tf.dabeeo.com>
Co-committed-by: Jinseok (심진석) <jinseok.sim@tf.dabeeo.com>
2026-04-08 15:54:25 +09:00
d99422d328 라우트 추가 (#2)
Reviewed-on: #2
Co-authored-by: Jinseok (심진석) <jinseok.sim@tf.dabeeo.com>
Co-committed-by: Jinseok (심진석) <jinseok.sim@tf.dabeeo.com>
2026-04-08 15:18:58 +09:00
d1fdae63ac feat: init (#1)
## webpack vs vite
vite는 최근 webpack 대신 많이 채택되고 있는 빌드 도구. 내부는 rollup이나 dev에선 esbuild, native esm을 사용하여 속도가 빠름. 반면 webpack은 관련된 모든 파일을 번들링 해야하기 때문에 개발에서 빌드 속도 차이가 수 초 이상 발생하게됨

## react-router vs tanstack router
react router는 리액트 초창기부터 사용되어져왔고, tanstack router는 비교적 최근에 생겨났는데, 타입 안정성에 신경을 쓰다보니 라우트를 위해 신경써야할 장치들이 있고, export 해야할 데이터가 달라 처음 사용하는 사람은 혼란이 있을 수 있음. 또한 그에 따른 러닝커브가 존재하여 react-router를 선택

Reviewed-on: #1
Co-authored-by: Jinseok (심진석) <jinseok.sim@tf.dabeeo.com>
Co-committed-by: Jinseok (심진석) <jinseok.sim@tf.dabeeo.com>
2026-04-08 10:43:41 +09:00
d3efb9e2d2 code 추가 2026-04-08 10:10:12 +09:00
184 changed files with 15885 additions and 4 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/api-app/app/src/main/resources/application-local.yml
api-app/.gradle
api-app/build

View File

@@ -1,3 +1,232 @@
### 다비오 변화 탐지 시스템
# Dabeeo Detection API
## 웹어플리케이션
> Dabeeo 변화 탐지 시스템을 위한 백엔드 API 서버
---
## 📋 프로젝트 소개
**dabeeo-detection-api**
본 프로젝트는 영상 데이터를 기반으로 변화 탐지를 수행하는 시스템의 백엔드 API 서버입니다.
---
## 🛠️ 기술 스택
| Category | Technology |
|----------|------------|
| **Language** | Java 21 |
| **Framework** | Spring Boot 3.5.7 |
| **Database** | PostgreSQL (with PostGIS) |
| **ORM** | Spring Data JPA + Hibernate |
| **Query** | QueryDSL 5.0.0 (Jakarta) |
| **Geospatial** | JTS (Java Topology Suite) + GeoJSON |
| **Connection Pool** | HikariCP |
| **Build Tool** | Gradle 8.x |
| **Monitoring** | Spring Boot Actuator |
---
## 🚀 시작하기
### 필수 요구사항
- Java 21 (JDK 21)
- PostgreSQL 12+ (PostGIS 확장 필요)
- Gradle 8.x (또는 Gradle Wrapper 사용)
- Docker & Docker Compose (선택사항)
---
### 로컬 환경 설정
#### 1. 저장소 클론
https://kamco.git.gs.dabeeo.com/MVPTeam/DABEEO-DETECTION-APPLICATION.git
---
#### 2. 데이터베이스 설정
PostgreSQL 데이터베이스를 준비하고
`src/main/resources/application-local.yml` 파일을 생성합니다.
```yaml
spring:
config:
activate:
on-profile: local
datasource:
url: jdbc:postgresql://localhost:5432/dabeeo_detection_dev
username: your_username
password: your_password
```
> ⚠️ `application-local.yml`은 `.gitignore`에 포함되어 Git에 커밋되지 않습니다.
---
#### 3. 빌드 및 실행
```bash
# 빌드
./gradlew build
# 실행 (local 프로파일)
./gradlew bootRun
# 또는 JAR 실행
java -jar build/libs/ROOT.jar
```
서버 실행 후:
http://localhost:8080
---
## ⚙️ 프로파일 설정
| 프로파일 | 환경 | 포트 | 설정 파일 |
|---------|------|------|-----------|
| `local` | 로컬 개발 | 8080 | `application.yml` |
| `dev` | 개발 서버 | 8080 | `application-dev.yml` |
| `prod` | 운영 서버 | 8080 | `application-prod.yml` |
---
### 프로파일 실행 방법
```bash
# dev 실행
./gradlew bootRun --args='--spring.profiles.active=dev'
# jar 실행
java -jar build/libs/ROOT.jar --spring.profiles.active=dev
```
---
## 🧪 테스트
```bash
# 전체 테스트
./gradlew test
# 특정 테스트
./gradlew test --tests com.kamco.cd.kamcoback.KamcoBackApplicationTests
# 리포트 확인
open build/reports/tests/test/index.html
```
---
## 📦 빌드
```bash
# 전체 빌드
./gradlew clean build
# 테스트 제외
./gradlew clean build -x test
# JAR 생성
./gradlew bootJar
```
빌드 결과:
build/libs/ROOT.jar
---
## 📁 프로젝트 구조
### (2026-04-08 기준)
```
api-app # 메인 실행 모듈
├── app
│ ├── src/main
│ │ ├── java/com/cd/detection
│ │ │ ├── DabeeoDetectionApiApplication.java
│ │ │
│ │ │ ├── config # 설정 파일
│ │ │ ├── db
│ │ │ │ ├── core # core service (Repository 비즈니스 서비스)
│ │ │ │ └── repository # Repository (DB 인터페이스/구현)
│ │ │ ├── core
│ │ │ ├── domain # 도메인
│ │ │ │ ├── imagery # 영상 데이터 관리
│ │ │ │ ├── labeling # 라벨링 툴
│ │ │ │ ├── label # 라벨링 관리
│ │ │ │ ├── model # 모델관리
│ │ │ │ ├── inference # 추론관리
│ │ │ │ ├── system # 시스템관리
│ │ │ │ └── log # 로그관리
│ │ │ ├── entity # 공용 JPA 엔티티 (DB 공통 사용)
│ │ │
│ │ └── resources # 설정 및 리소스 파일
│ │ ├── application.yml
│ │ ├── application-dev.yml
│ │ └── application-prod.yml
│ │
│ └── build.gradle
├── infrastructure-db-postgres # Postgres 전용 설정/확장
│ └── build.gradle
├── build.gradle
├── settings.gradle
├── gradle/
└── gradlew
```
---
## 🧩 아키텍처 설계
본 프로젝트는 API 모듈과 DB 모듈을 분리한 멀티 모듈 구조를 사용합니다.
---
### 📌 모듈 구성
| 모듈 | 설명 |
|------|------|
| api-app | API 및 비즈니스 로직 |
| infrastructure-db-postgres | DB 접근 및 설정 |
---
## 🗄️ Database Module 분리 전략
DB 관련 로직은 별도의 모듈(infrastructure-db-postgres)로 분리되어 관리됩니다.
---
### 🎯 분리 목적
- 기술 의존성 분리
- DB 교체 용이성
- 관심사 분리
- 확장성 확보
---
### 🔄 Database 교체
- `api-app/build.gradle`에서 DB 모듈 의존성 변경
(`infrastructure-db-postgres``infrastructure-db-oracle`)
- `application.yml` 또는 DB별 설정 파일(`application-postgres.yml`, `application-oracle.yml`)에서
데이터베이스 연결 정보 변경
- 실행 시 profile을 통해 DB 선택
```bash
# PostgreSQL
--spring.profiles.active=postgres
# Oracle
--spring.profiles.active=oracle

41
api-app/app/build.gradle Normal file
View File

@@ -0,0 +1,41 @@
plugins {
id 'org.springframework.boot'
id 'java'
}
dependencies {
// DB 변경시 변경
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-data-jpa'
// OpenAPI
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
// QueryDSL
implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Devtools
developmentOnly 'org.springframework.boot:spring-boot-devtools'
// QueryDSL APT
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
bootJar {
archiveFileName = 'ROOT.jar'
}

View File

@@ -0,0 +1,16 @@
package com.cd.detection;
import org.springframework.boot.SpringApplication;
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
public class DabeeoDetectionApiApplication {
public static void main(String[] args) {
SpringApplication.run(DabeeoDetectionApiApplication.class, args);
}
}

View File

@@ -0,0 +1,19 @@
package com.cd.detection.config;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
});
}
}

View File

@@ -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;
});
}
}

View File

@@ -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 {
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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 {
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,15 @@
spring:
datasource:
url: jdbc:h2:mem:testdb
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
h2:
console:
enabled: true

View File

@@ -0,0 +1,15 @@
spring:
datasource:
url: jdbc:h2:mem:testdb
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
h2:
console:
enabled: true

View File

@@ -0,0 +1,20 @@
spring:
profiles:
active: local
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
management:
endpoints:
web:
base-path: /monitor
endpoint:
health:
probes:
enabled: true

30
api-app/build.gradle Normal file
View File

@@ -0,0 +1,30 @@
plugins {
id 'org.springframework.boot' version '3.5.7' apply false
id 'io.spring.dependency-management' version '1.1.7' apply false
}
allprojects {
group = 'com.cd.detection'
version = '0.0.1-SNAPSHOT'
repositories {
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"
}
}
}

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
api-app/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
api-app/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,8 @@
plugins {
id 'java'
}
dependencies {
// DB 드라이버
implementation 'org.postgresql:postgresql:42.7.3'
}

4
api-app/settings.gradle Normal file
View File

@@ -0,0 +1,4 @@
rootProject.name = 'dabeeo-detection-api'
include 'app'
include 'infrastructure-db-postgres'

16
web-app/.editorconfig Normal file
View File

@@ -0,0 +1,16 @@
# EditorConfig is awesome: https://editorconfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,jsx,ts,tsx}]
charset = utf-8
insert_final_newline = true
indent_style = space
indent_size = 2

49
web-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,49 @@
# Dependencies
node_modules
.pnpm-store
# Build outputs
build/
dist/
.output/
# React Router
.react-router/
# Vite
.vite/
vite.config.ts.timestamp-*
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# IDE
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
.cache/
# TypeScript
*.tsbuildinfo

View 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

View 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

View 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,
},
},
})

26
web-app/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:24-alpine AS development-dependencies-env
RUN npm install -g corepack && corepack enable
COPY . /app
WORKDIR /app
RUN pnpm install --frozen-lockfile
FROM node:24-alpine AS production-dependencies-env
RUN npm install -g corepack && corepack enable
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm install --frozen-lockfile --prod
FROM node:24-alpine AS build-env
RUN npm install -g corepack && corepack enable
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm run build
FROM node:24-alpine
RUN npm install -g corepack && corepack enable
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "run", "start"]

View File

@@ -1,3 +1,144 @@
### 다비오 변화 탐지 시스템
# 다비오 변화 탐지 시스템 - Web Application
## API application
React Router 7 기반 SPA 웹 애플리케이션
## 기술 스택
| 분류 | 기술 |
|------|------|
| Framework | React 19 + React Router 7 (SPA) |
| Language | TypeScript 5.9 (strict) |
| Styling | Tailwind CSS 4.2 |
| Build Tool | Vite 8 |
| Package Manager | pnpm (corepack) |
| Code Quality | ESLint 10 + Prettier |
| Container | Docker + Docker Compose |
| Map | OpenLayers 10.8 |
| UI Components | React Aria Components 1.16 |
| Form | React Hook Form 7.72 + Zod 4.3 |
| Date | Day.js 1.11 |
## 사전 요구사항
- **Node.js** 24+
- **corepack** 활성화 (`corepack enable` 실행 시 pnpm 자동 설치)
- **Docker & Docker Compose** (선택, 컨테이너 환경 사용 시)
## 시작하기
### 로컬 개발
```bash
corepack enable
pnpm install
pnpm dev
```
개발 서버가 `http://localhost:5173`에서 실행됩니다.
### Docker 개발
```bash
docker compose up
```
소스 코드가 볼륨 마운트되어 HMR이 동작합니다. `http://localhost:5173`에서 접근 가능합니다.
## 프로젝트 구조
```
web-app/
├── app/
│ ├── root.tsx # Root layout, ErrorBoundary
│ ├── app.css # Tailwind 글로벌 스타일
│ ├── routes.ts # 라우트 정의
│ ├── routes/
│ │ ├── catch-all.tsx # 404 처리
│ │ ├── code/
│ │ │ └── page.tsx # 공통코드 관리
│ │ ├── hyper-parameter/
│ │ │ └── page.tsx # 하이퍼파라미터 설정
│ │ ├── imagery/
│ │ │ ├── aerial/
│ │ │ │ ├── page.tsx # 항공영상 목록
│ │ │ │ └── [id]/
│ │ │ │ └── page.tsx # 항공영상 상세
│ │ │ ├── satellite/
│ │ │ │ ├── page.tsx # 위성영상 목록
│ │ │ │ └── [id]/
│ │ │ │ └── page.tsx # 위성영상 상세
│ │ │ └── drone/
│ │ │ ├── page.tsx # 드론영상 목록
│ │ │ └── [id]/
│ │ │ └── page.tsx # 드론영상 상세
│ │ ├── inference/
│ │ │ ├── page.tsx # 추론 목록
│ │ │ └── [id]/
│ │ │ └── page.tsx # 추론 상세
│ │ ├── labeling/
│ │ │ ├── label/
│ │ │ │ └── page.tsx # 라벨링 작업
│ │ │ └── review/
│ │ │ └── page.tsx # 라벨링 검수
│ │ ├── model/
│ │ │ ├── page.tsx # 모델 목록
│ │ │ └── [id]/
│ │ │ └── page.tsx # 모델 상세
│ │ ├── log/
│ │ │ ├── audit/
│ │ │ │ └── page.tsx # 감사 로그
│ │ │ └── system/
│ │ │ └── page.tsx # 시스템 로그
│ │ ├── login/
│ │ │ └── page.tsx # 로그인
│ │ ├── schedule/
│ │ │ └── page.tsx # 스케줄 관리
│ │ └── user/
│ │ └── page.tsx # 사용자 관리
├── public/ # 정적 파일
├── Dockerfile # 프로덕션 빌드 (multi-stage)
├── docker-compose.yml # 개발 환경
├── vite.config.ts # Vite 설정
├── react-router.config.ts # React Router 설정
├── tsconfig.json # TypeScript 설정
└── eslint.config.js # ESLint 설정
```
> **경로 별칭**: `~/`는 `./app/`을 가리킵니다. (`tsconfig.json`에서 설정)
## 스크립트
| 명령어 | 설명 |
|--------|------|
| `pnpm dev` | 개발 서버 실행 (Vite HMR) |
| `pnpm build` | 프로덕션 빌드 |
| `pnpm start` | 프로덕션 서버 실행 |
| `pnpm typecheck` | TypeScript 타입 검사 |
| `pnpm lint` | ESLint 검사 |
| `pnpm lint:fix` | ESLint 자동 수정 |
## Docker
### 개발 환경 (Docker Compose)
```bash
docker compose up
```
- 소스 코드를 컨테이너에 볼륨 마운트하여 실시간 반영
- `node_modules`는 별도 named volume으로 관리
- 포트: `5173`
### 프로덕션 빌드 (Dockerfile)
```bash
docker build -t dabeeo-web .
docker run -p 3000:3000 dabeeo-web
```
Multi-stage 빌드로 최적화된 이미지를 생성합니다:
1. **development-dependencies-env** - 전체 의존성 설치 (빌드 도구 포함)
2. **production-dependencies-env** - 프로덕션 의존성만 설치
3. **build-env** - 애플리케이션 빌드
4. **final** - 프로덕션 의존성 + 빌드 결과물만 포함하여 실행

92
web-app/app/app.css Normal file
View File

@@ -0,0 +1,92 @@
@import "tailwindcss";
@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 {
--font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif,
"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,
body {
}

View 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;
}
}

View 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;
}
};

View 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)} />}
</>
);
}

View 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>
);
};

View 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,
}
}

View 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: '도곽';
};

View 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;
};

65
web-app/app/root.tsx Normal file
View File

@@ -0,0 +1,65 @@
import type { Route } from './+types/root';
import './app.css';
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from 'react-router';
export const links: Route.LinksFunction = () => [];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details
= error.status === 404
? 'The requested page could not be found.'
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

50
web-app/app/routes.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { RouteConfig } from '@react-router/dev/routes';
import { index, route, layout, prefix } from '@react-router/dev/routes';
export default [
layout('./routes/login/layout.tsx', [
route('login', './routes/login/page.tsx'),
]),
layout('./routes/layout.tsx', [
index('./routes/page.tsx'),
...prefix('imagery', [
...prefix('aerial', [
index('./routes/imagery/aerial/page.tsx'),
route(':aerialId', './routes/imagery/aerial/[id]/page.tsx'),
]),
...prefix('satellite', [
index('./routes/imagery/satellite/page.tsx'),
route(':satelliteId', './routes/imagery/satellite/[id]/page.tsx'),
]),
...prefix('drone', [
index('./routes/imagery/drone/page.tsx'),
route(':droneId', './routes/imagery/drone/[id]/page.tsx'),
]),
]),
...prefix('inference', [
index('./routes/inference/page.tsx'),
route(':inferenceId', './routes/inference/[id]/page.tsx'),
]),
...prefix('model', [
index('./routes/model/page.tsx'),
route(':modelId', './routes/model/[id]/page.tsx'),
]),
...prefix('labeling', [
route('label', './routes/labeling/label/page.tsx'),
route('review', './routes/labeling/review/page.tsx'),
]),
...prefix('log', [
route('audit', './routes/log/audit/page.tsx'),
route('system', './routes/log/system/page.tsx'),
]),
...prefix('schedule', [
index('./routes/schedule/page.tsx'),
]),
route('code', './routes/code/page.tsx'),
route('hyper-parameter', './routes/hyper-parameter/page.tsx'),
route('user', './routes/user/page.tsx'),
]),
route('*', './routes/catch-all.tsx'),
] satisfies RouteConfig;

View File

@@ -0,0 +1,3 @@
export function clientLoader() {
throw new Response("Not Found", { status: 404 });
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G1 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G2 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G3 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G1 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G2 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> G3 </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,9 @@
import type { Route } from './+types/page';
export default function Page({ params }: Route.ComponentProps) {
return (
<div>
id: {params.aerialId}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { AerialList } from '~/features/imagery/components/AerialList';
export default function Page() {
return (
<AerialList></AerialList>
);
}

View File

@@ -0,0 +1,9 @@
import type { Route } from './+types/page';
export default function Page({ params }: Route.ComponentProps) {
return (
<div>
id: {params.droneId}
</div>
);
}

View File

@@ -0,0 +1,5 @@
export default function Page() {
return (
<div> </div>
);
}

View File

@@ -0,0 +1,9 @@
import type { Route } from './+types/page';
export default function Page({ params }: Route.ComponentProps) {
return (
<div>
id: {params.satelliteId}
</div>
);
}

View File

@@ -0,0 +1,5 @@
export default function Page() {
return (
<div> </div>
);
}

View File

@@ -0,0 +1,5 @@
import type { Route } from './+types/page';
export default function Page({ params }: Route.ComponentProps) {
return <div> id: {params.inferenceId}</div>;
}

View File

@@ -0,0 +1,7 @@
export default function Page() {
return (
<div>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,33 @@
import { Outlet } from 'react-router';
import { LayoutMenu } from '~/shared/components/menu/LayoutMenu';
import { MENU_ITEMS } from '~/shared/constants/menu';
export default function Layout() {
return (
<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)]">
<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>
);
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,10 @@
import { Outlet } from 'react-router';
export default function Layout() {
return (
<div>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,5 @@
export default function Page() {
return (
<div> </div>
);
}

View File

@@ -0,0 +1,5 @@
import type { Route } from './+types/page';
export default function Page({ params }: Route.ComponentProps) {
return <div> id: {params.modelId}</div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div></div>;
}

View File

@@ -0,0 +1,5 @@
import { Navigate } from 'react-router';
export default function Page() {
return <Navigate to="/imagery/aerial" replace />;
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div> </div>;
}

Some files were not shown because too many files have changed in this diff Show More