Compare commits
5 Commits
feature/sp
...
feature/sp
| Author | SHA1 | Date | |
|---|---|---|---|
| e48aae1f04 | |||
| 0b23deca48 | |||
| e0041172a7 | |||
| 52732e88b9 | |||
|
|
1c8d015810 |
92
.qa/SPRINT-002-review-1.md
Normal file
92
.qa/SPRINT-002-review-1.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# SPRINT-002 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 02:41 UTC
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ❌ FAILED (blocking 2건)
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install (backend) | ✅ |
|
||||||
|
| npm install (frontend) | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test (backend) | ✅ 11/11 pass |
|
||||||
|
| npm run build (backend) | ✅ |
|
||||||
|
| npm run build (frontend) | ✅ |
|
||||||
|
| FE SSR 렌더링 확인 | ✅ |
|
||||||
|
| /projects/[id] 동적 라우트 | ✅ |
|
||||||
|
|
||||||
|
## Sprint 001 non-blocking 수정 확인
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| CORS 화이트리스트 | ✅ 해결 |
|
||||||
|
| DATABASE_URL throw | ✅ 해결 |
|
||||||
|
| SSH_KEY_PATH throw | ✅ 해결 |
|
||||||
|
| SSH 에러 마스킹 | ✅ 해결 |
|
||||||
|
| LayoutShell 분리 | ✅ 해결 |
|
||||||
|
| Sidebar↔MainContent 연동 | ✅ 해결 |
|
||||||
|
| API 응답 IP 제거 | ✅ 해결 |
|
||||||
|
| console.log → Logger | ✅ 해결 |
|
||||||
|
| theme.ts 적용 | ✅ 직접 import 패턴 (OK) |
|
||||||
|
| npm audit 7개 | ❌ 미해결 (devDep chain) |
|
||||||
|
|
||||||
|
## Sprint 002 설계 대조
|
||||||
|
|
||||||
|
| 설계 항목 | 일치 |
|
||||||
|
|-----------|------|
|
||||||
|
| GiteaService (org repos, repo detail, PRs) | ✅ |
|
||||||
|
| GET /api/projects | ✅ |
|
||||||
|
| GET /api/projects/:id | ✅ |
|
||||||
|
| GET /api/projects/:id/tasks | ✅ |
|
||||||
|
| POST /api/projects/:id/sprints | ✅ |
|
||||||
|
| PATCH /api/tasks/:id | ✅ |
|
||||||
|
| GET /api/activity (limit, offset) | ✅ |
|
||||||
|
| GET /api/projects/:id/activity | ✅ |
|
||||||
|
| SprintAccordion 컴포넌트 | ✅ |
|
||||||
|
| TaskTable + 필터링 | ✅ |
|
||||||
|
| TabNav (Sprints/Tasks/Activity) | ✅ |
|
||||||
|
| ProgressBar | ✅ |
|
||||||
|
| TaskBadge (7개 상태) | ✅ |
|
||||||
|
| ActivityFeed 타임라인 | ✅ |
|
||||||
|
| ProjectCard + 링크 | ✅ |
|
||||||
|
| 메인 대시보드 프로젝트 섹션 | ✅ |
|
||||||
|
| 메인 대시보드 활동 피드 | ✅ |
|
||||||
|
|
||||||
|
## 🔴 Blocking 이슈
|
||||||
|
|
||||||
|
### CR-001: GiteaService GITEA_BASE_URL/GITEA_TOKEN 미설정 시 앱 부팅 실패
|
||||||
|
- **파일:** `backend/src/gitea/gitea.service.ts`
|
||||||
|
- **문제:** constructor에서 `throw new Error()` → DI 실패 → 앱 전체 부팅 실패
|
||||||
|
- **설계 불일치:** 핸드오프에서 "Gitea API 토큰 없으면 graceful fallback으로 DB only"라고 명시했으나 코드는 throw
|
||||||
|
- **수정:** Optional 패턴으로 변경. client를 null로 두고 메서드에서 빈 배열/null 반환
|
||||||
|
|
||||||
|
### CR-002: CreateSprintDto/UpdateTaskDto에 class-validator 미적용
|
||||||
|
- **파일:** `backend/src/tasks/tasks.service.ts`
|
||||||
|
- **문제:** plain class DTO — ValidationPipe 없이 아무 값이나 DB 직행
|
||||||
|
- **위험:** status에 임의 문자열, number에 음수, 예상 외 필드 주입 가능
|
||||||
|
- **수정:** class-validator 데코레이터 추가 + main.ts에 ValidationPipe 적용
|
||||||
|
|
||||||
|
## Non-blocking 이슈
|
||||||
|
|
||||||
|
### 코드 품질 (code-reviewer)
|
||||||
|
| ID | 이슈 |
|
||||||
|
|----|------|
|
||||||
|
| CR-003 | iteration 증가 로직 — 정확하지만 메서드 분리로 가독성 개선 권장 |
|
||||||
|
| CR-004 | limit/offset Query 파라미터 NaN 미처리 — ParseIntPipe 적용 권장 |
|
||||||
|
| CR-006 | giteaRepos 키 매칭 — giteaId null 방어 필요 |
|
||||||
|
| CR-007 | ProjectDetailPage useState<any> 3곳 — 인터페이스 정의 권장 |
|
||||||
|
| CR-008 | CORS 에러에 origin 값 노출 — generic 에러 반환 권장 |
|
||||||
|
| CR-009 | npm audit 7개 — devDependency 확인 후 prod 0이면 수용 가능 |
|
||||||
|
|
||||||
|
### 보안 감사 (security-auditor)
|
||||||
|
| ID | 이슈 | 심각도 |
|
||||||
|
|----|------|--------|
|
||||||
|
| SA-005 | API 인증 없음 (전체) — Sprint-003 필수 | Medium |
|
||||||
|
| SA-006 | PATCH /tasks/:id 인증 없음 (write) | High |
|
||||||
|
| SA-007 | POST /projects/:id/sprints 인증 없음 (write) | High |
|
||||||
|
| SA-008 | limit/offset 클램핑 없음 (대량 조회 가능) | Medium |
|
||||||
|
| SA-012 | Helmet 미적용 | Medium |
|
||||||
|
| SA-013 | Rate limiting 없음 | Medium |
|
||||||
|
| SA-014 | GiteaService 에러 시 Authorization 헤더 노출 가능 | Medium |
|
||||||
29
.qa/SPRINT-002-review-2.md
Normal file
29
.qa/SPRINT-002-review-2.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# SPRINT-002 QA Review — Iteration 2
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 02:44 UTC
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## Blocking 수정 확인
|
||||||
|
|
||||||
|
| ID | 이슈 | 수정 확인 |
|
||||||
|
|----|------|----------|
|
||||||
|
| CR-001 | GiteaService throw → graceful fallback | ✅ client=null + isAvailable() 가드 + 빈 배열/null 반환 |
|
||||||
|
| CR-002 | DTO class-validator + ValidationPipe | ✅ @IsNumber/@Min/@IsString/@IsOptional/@IsIn 적용 + main.ts ValidationPipe(whitelist, forbidNonWhitelisted, transform) |
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git fetch + reset | ✅ |
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test (backend) | ✅ 11/11 pass |
|
||||||
|
| npm run build (backend) | ✅ |
|
||||||
|
| npm run build (frontend) | ✅ |
|
||||||
|
|
||||||
|
## 판정 근거
|
||||||
|
- Iteration 1의 blocking 2건 모두 정확하게 수정됨
|
||||||
|
- 테스트/빌드 전부 통과
|
||||||
|
- 설계 문서와 코드 일치 (Iteration 1에서 이미 확인)
|
||||||
|
- Non-blocking 이슈 14건은 Sprint-003에서 해결 권고
|
||||||
91
backend/package-lock.json
generated
91
backend/package-lock.json
generated
@@ -15,6 +15,9 @@
|
|||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@prisma/adapter-mariadb": "^7.6.0",
|
"@prisma/adapter-mariadb": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
|
"axios": "^1.14.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
"mariadb": "^3.5.2",
|
"mariadb": "^3.5.2",
|
||||||
"node-ssh": "^13.2.1",
|
"node-ssh": "^13.2.1",
|
||||||
@@ -25,7 +28,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.17",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
@@ -3263,6 +3266,12 @@
|
|||||||
"@types/superagent": "^8.1.0"
|
"@types/superagent": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/validator": {
|
||||||
|
"version": "13.15.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||||
|
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@@ -4280,7 +4289,6 @@
|
|||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/aws-ssl-profiles": {
|
"node_modules/aws-ssl-profiles": {
|
||||||
@@ -4292,6 +4300,17 @@
|
|||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.11",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"proxy-from-env": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-jest": {
|
"node_modules/babel-jest": {
|
||||||
"version": "30.3.0",
|
"version": "30.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
|
||||||
@@ -4843,6 +4862,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/class-transformer": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/class-validator": {
|
||||||
|
"version": "0.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz",
|
||||||
|
"integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/validator": "^13.15.3",
|
||||||
|
"libphonenumber-js": "^1.11.1",
|
||||||
|
"validator": "^13.15.22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cli-cursor": {
|
"node_modules/cli-cursor": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||||
@@ -4980,7 +5016,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"delayed-stream": "~1.0.0"
|
"delayed-stream": "~1.0.0"
|
||||||
@@ -5281,7 +5316,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
@@ -5540,7 +5574,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -6141,6 +6174,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
@@ -6189,7 +6242,6 @@
|
|||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
@@ -6206,7 +6258,6 @@
|
|||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
@@ -6216,7 +6267,6 @@
|
|||||||
"version": "2.1.35",
|
"version": "2.1.35",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "1.52.0"
|
"mime-db": "1.52.0"
|
||||||
@@ -6604,7 +6654,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
@@ -7929,6 +7978,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libphonenumber-js": {
|
||||||
|
"version": "1.12.41",
|
||||||
|
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.41.tgz",
|
||||||
|
"integrity": "sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -9113,6 +9168,15 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -10727,6 +10791,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/validator": {
|
||||||
|
"version": "13.15.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
|
||||||
|
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@prisma/adapter-mariadb": "^7.6.0",
|
"@prisma/adapter-mariadb": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
|
"axios": "^1.14.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
"mariadb": "^3.5.2",
|
"mariadb": "^3.5.2",
|
||||||
"node-ssh": "^13.2.1",
|
"node-ssh": "^13.2.1",
|
||||||
@@ -40,7 +43,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.17",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
|||||||
31
backend/src/activity/activity.controller.ts
Normal file
31
backend/src/activity/activity.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Controller, Get, Param, Query, ParseIntPipe } from '@nestjs/common';
|
||||||
|
import { ActivityService } from './activity.service';
|
||||||
|
|
||||||
|
@Controller('api')
|
||||||
|
export class ActivityController {
|
||||||
|
constructor(private readonly activityService: ActivityService) {}
|
||||||
|
|
||||||
|
@Get('activity')
|
||||||
|
getFeed(
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('offset') offset?: string,
|
||||||
|
) {
|
||||||
|
return this.activityService.getFeed(
|
||||||
|
limit ? parseInt(limit, 10) : 50,
|
||||||
|
offset ? parseInt(offset, 10) : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('projects/:id/activity')
|
||||||
|
getProjectFeed(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('offset') offset?: string,
|
||||||
|
) {
|
||||||
|
return this.activityService.getProjectFeed(
|
||||||
|
id,
|
||||||
|
limit ? parseInt(limit, 10) : 30,
|
||||||
|
offset ? parseInt(offset, 10) : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/activity/activity.module.ts
Normal file
12
backend/src/activity/activity.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ActivityController } from './activity.controller';
|
||||||
|
import { ActivityService } from './activity.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [ActivityController],
|
||||||
|
providers: [ActivityService],
|
||||||
|
exports: [ActivityService],
|
||||||
|
})
|
||||||
|
export class ActivityModule {}
|
||||||
57
backend/src/activity/activity.service.spec.ts
Normal file
57
backend/src/activity/activity.service.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ActivityService } from './activity.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('ActivityService', () => {
|
||||||
|
let service: ActivityService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
activityLog: {
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 1, action: 'test', createdAt: new Date() }),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ActivityService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<ActivityService>(ActivityService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('log: activityLog.create 호출', async () => {
|
||||||
|
await service.log({ action: 'test_action', detail: 'detail' });
|
||||||
|
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith({
|
||||||
|
data: { action: 'test_action', detail: 'detail' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFeed: items + total 반환', async () => {
|
||||||
|
mockPrisma.activityLog.findMany.mockResolvedValue([{ id: 1 }]);
|
||||||
|
mockPrisma.activityLog.count.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const result = await service.getFeed(10, 0);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.limit).toBe(10);
|
||||||
|
expect(result.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getProjectFeed: projectId 필터 적용', async () => {
|
||||||
|
mockPrisma.activityLog.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.activityLog.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getProjectFeed(42);
|
||||||
|
|
||||||
|
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { projectId: 42 } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
backend/src/activity/activity.service.ts
Normal file
50
backend/src/activity/activity.service.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface LogActivityDto {
|
||||||
|
sisterId?: number;
|
||||||
|
projectId?: number;
|
||||||
|
action: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivityService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async log(dto: LogActivityDto) {
|
||||||
|
return this.prisma.activityLog.create({ data: dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFeed(limit = 50, offset = 0) {
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.activityLog.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
include: {
|
||||||
|
sister: { select: { name: true } },
|
||||||
|
project: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.activityLog.count(),
|
||||||
|
]);
|
||||||
|
return { items, total, limit, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectFeed(projectId: number, limit = 30, offset = 0) {
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.activityLog.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
include: {
|
||||||
|
sister: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.activityLog.count({ where: { projectId } }),
|
||||||
|
]);
|
||||||
|
return { items, total, limit, offset };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import { AppService } from './app.service';
|
|||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { SistersModule } from './sisters/sisters.module';
|
import { SistersModule } from './sisters/sisters.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { GiteaModule } from './gitea/gitea.module';
|
||||||
|
import { ProjectsModule } from './projects/projects.module';
|
||||||
|
import { TasksModule } from './tasks/tasks.module';
|
||||||
|
import { ActivityModule } from './activity/activity.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -12,6 +16,10 @@ import { HealthModule } from './health/health.module';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
SistersModule,
|
SistersModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
GiteaModule,
|
||||||
|
ProjectsModule,
|
||||||
|
TasksModule,
|
||||||
|
ActivityModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
10
backend/src/gitea/gitea.module.ts
Normal file
10
backend/src/gitea/gitea.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { GiteaService } from './gitea.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [GiteaService],
|
||||||
|
exports: [GiteaService],
|
||||||
|
})
|
||||||
|
export class GiteaModule {}
|
||||||
90
backend/src/gitea/gitea.service.ts
Normal file
90
backend/src/gitea/gitea.service.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
export interface GiteaRepo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
description: string;
|
||||||
|
html_url: string;
|
||||||
|
default_branch: string;
|
||||||
|
open_issues_count: number;
|
||||||
|
open_pr_counter: number;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaPR {
|
||||||
|
id: number;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
html_url: string;
|
||||||
|
user: { login: string };
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GiteaService {
|
||||||
|
private readonly logger = new Logger(GiteaService.name);
|
||||||
|
private readonly client: AxiosInstance;
|
||||||
|
private readonly org: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
const baseURL = config.get<string>('GITEA_BASE_URL');
|
||||||
|
const token = config.get<string>('GITEA_TOKEN');
|
||||||
|
this.org = config.get<string>('GITEA_ORG') ?? 'hanarang';
|
||||||
|
|
||||||
|
if (!baseURL || !token) {
|
||||||
|
this.logger.warn('GITEA_BASE_URL or GITEA_TOKEN not set — Gitea features disabled');
|
||||||
|
this.client = null as any;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: `${baseURL}/api/v1`,
|
||||||
|
headers: { Authorization: `token ${token}` },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAvailable(): boolean {
|
||||||
|
return this.client !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrgRepos(): Promise<GiteaRepo[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaRepo[]>(`/orgs/${this.org}/repos`, {
|
||||||
|
params: { limit: 50 },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('Failed to fetch Gitea repos');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRepo(repoName: string): Promise<GiteaRepo | null> {
|
||||||
|
if (!this.isAvailable()) return null;
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaRepo>(`/repos/${this.org}/${repoName}`);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpenPRs(repoName: string): Promise<GiteaPR[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaPR[]>(
|
||||||
|
`/repos/${this.org}/${repoName}/pulls`,
|
||||||
|
{ params: { state: 'open', limit: 20 } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
app.enableCors();
|
|
||||||
await app.listen(process.env.BACKEND_PORT ?? 3005);
|
const allowedOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3004')
|
||||||
console.log(`🚀 Backend running on port ${process.env.BACKEND_PORT ?? 3005}`);
|
.split(',')
|
||||||
|
.map((o) => o.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
if (!origin || allowedOrigins.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
callback(new Error(`CORS: ${origin} not allowed`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
|
||||||
|
|
||||||
|
const port = process.env.BACKEND_PORT ?? 3005;
|
||||||
|
await app.listen(port);
|
||||||
|
Logger.log(`🚀 Backend running on port ${port}`, 'Bootstrap');
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { PrismaMariaDb } from '@prisma/adapter-mariadb';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
constructor(config: ConfigService) {
|
constructor(config: ConfigService) {
|
||||||
const dbUrl = config.get<string>('DATABASE_URL') ?? '';
|
const dbUrl = config.get<string>('DATABASE_URL');
|
||||||
|
if (!dbUrl) {
|
||||||
|
throw new Error('DATABASE_URL is not set. Check your .env file.');
|
||||||
|
}
|
||||||
const adapter = new PrismaMariaDb(dbUrl);
|
const adapter = new PrismaMariaDb(dbUrl);
|
||||||
super({ adapter });
|
super({ adapter });
|
||||||
}
|
}
|
||||||
|
|||||||
17
backend/src/projects/projects.controller.ts
Normal file
17
backend/src/projects/projects.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
|
||||||
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
|
@Controller('api/projects')
|
||||||
|
export class ProjectsController {
|
||||||
|
constructor(private readonly projectsService: ProjectsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getProjects() {
|
||||||
|
return this.projectsService.getProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
getProjectById(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.projectsService.getProjectById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/projects/projects.module.ts
Normal file
13
backend/src/projects/projects.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ProjectsController } from './projects.controller';
|
||||||
|
import { ProjectsService } from './projects.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { GiteaModule } from '../gitea/gitea.module';
|
||||||
|
import { ActivityModule } from '../activity/activity.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, GiteaModule, ActivityModule],
|
||||||
|
controllers: [ProjectsController],
|
||||||
|
providers: [ProjectsService],
|
||||||
|
})
|
||||||
|
export class ProjectsModule {}
|
||||||
82
backend/src/projects/projects.service.ts
Normal file
82
backend/src/projects/projects.service.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { GiteaService } from '../gitea/gitea.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectsService {
|
||||||
|
private readonly logger = new Logger(ProjectsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly gitea: GiteaService,
|
||||||
|
private readonly activity: ActivityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getProjects() {
|
||||||
|
// DB에 등록된 프로젝트 + Gitea 최신 정보 병합
|
||||||
|
const dbProjects = await this.prisma.project.findMany({
|
||||||
|
include: { sprints: { include: { tasks: true } } },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let giteaRepos: Record<number, { openPRs: number; updatedAt: string }> = {};
|
||||||
|
try {
|
||||||
|
const repos = await this.gitea.getOrgRepos();
|
||||||
|
for (const repo of repos) {
|
||||||
|
giteaRepos[repo.id] = {
|
||||||
|
openPRs: repo.open_pr_counter,
|
||||||
|
updatedAt: repo.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.warn('Gitea unavailable, using DB data only');
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbProjects.map((p) => {
|
||||||
|
const totalTasks = p.sprints.flatMap((s) => s.tasks).length;
|
||||||
|
const doneTasks = p.sprints.flatMap((s) => s.tasks).filter((t) => t.status === 'done').length;
|
||||||
|
const gitea = giteaRepos[p.giteaId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
giteaId: p.giteaId,
|
||||||
|
name: p.name,
|
||||||
|
repoUrl: p.repoUrl,
|
||||||
|
description: p.description,
|
||||||
|
status: p.status,
|
||||||
|
progress: totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0,
|
||||||
|
totalTasks,
|
||||||
|
doneTasks,
|
||||||
|
openPRs: gitea?.openPRs ?? 0,
|
||||||
|
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
|
||||||
|
sprintCount: p.sprints.length,
|
||||||
|
currentSprint: p.sprints.find((s) => s.status === 'in_progress')?.name ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectById(id: number) {
|
||||||
|
const project = await this.prisma.project.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
sprints: {
|
||||||
|
include: { tasks: { orderBy: { taskId: 'asc' } } },
|
||||||
|
orderBy: { number: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) throw new NotFoundException(`Project ${id} not found`);
|
||||||
|
|
||||||
|
let giteaPRs: ReturnType<typeof Array.prototype.map> = [];
|
||||||
|
try {
|
||||||
|
const repoName = project.repoUrl.split('/').pop() ?? '';
|
||||||
|
giteaPRs = await this.gitea.getOpenPRs(repoName);
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Gitea PR fetch failed for project ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...project, openPRs: giteaPRs };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,11 @@ describe('SistersService', () => {
|
|||||||
{ provide: PrismaService, useValue: mockPrisma },
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
{
|
{
|
||||||
provide: ConfigService,
|
provide: ConfigService,
|
||||||
useValue: { get: jest.fn().mockReturnValue('/home/narang/.ssh/id_rsa') },
|
useValue: { get: jest.fn().mockImplementation((key: string) => {
|
||||||
|
if (key === 'SSH_KEY_PATH') return '/home/narang/.ssh/id_rsa';
|
||||||
|
if (key === 'DATABASE_URL') return 'mysql://test:test@localhost:3306/test';
|
||||||
|
return undefined;
|
||||||
|
}) },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -54,6 +58,7 @@ describe('SistersService', () => {
|
|||||||
expect(result[0].status).toBe('online');
|
expect(result[0].status).toBe('online');
|
||||||
expect(result[0].name).toBe('harang');
|
expect(result[0].name).toBe('harang');
|
||||||
expect(result[0].role).toBe('Orchestrator');
|
expect(result[0].role).toBe('Orchestrator');
|
||||||
|
expect((result[0] as any).ip).toBeUndefined(); // IP 노출 제거 확인
|
||||||
});
|
});
|
||||||
|
|
||||||
it('SSH 실패 시 offline graceful fallback', async () => {
|
it('SSH 실패 시 offline graceful fallback', async () => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
export interface SisterStatus {
|
export interface SisterStatus {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
|
||||||
user: string;
|
user: string;
|
||||||
lxcId: number;
|
lxcId: number;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -34,7 +33,10 @@ export class SistersService {
|
|||||||
|
|
||||||
async getAllSistersStatus(): Promise<SisterStatus[]> {
|
async getAllSistersStatus(): Promise<SisterStatus[]> {
|
||||||
const sisters = await this.prisma.sisterConfig.findMany();
|
const sisters = await this.prisma.sisterConfig.findMany();
|
||||||
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH') ?? '/home/narang/.ssh/id_rsa';
|
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
|
||||||
|
if (!sshKeyPath) {
|
||||||
|
throw new Error('SSH_KEY_PATH is not set. Check your .env file.');
|
||||||
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
sisters.map((sister) => this.checkSisterStatus(sister, sshKeyPath)),
|
sisters.map((sister) => this.checkSisterStatus(sister, sshKeyPath)),
|
||||||
@@ -49,7 +51,6 @@ export class SistersService {
|
|||||||
return {
|
return {
|
||||||
id: sister.id,
|
id: sister.id,
|
||||||
name: sister.name,
|
name: sister.name,
|
||||||
ip: sister.ip,
|
|
||||||
user: sister.user,
|
user: sister.user,
|
||||||
lxcId: sister.lxcId,
|
lxcId: sister.lxcId,
|
||||||
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
||||||
@@ -86,7 +87,6 @@ export class SistersService {
|
|||||||
return {
|
return {
|
||||||
id: sister.id,
|
id: sister.id,
|
||||||
name: sister.name,
|
name: sister.name,
|
||||||
ip: sister.ip,
|
|
||||||
user: sister.user,
|
user: sister.user,
|
||||||
lxcId: sister.lxcId,
|
lxcId: sister.lxcId,
|
||||||
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
||||||
@@ -95,11 +95,10 @@ export class SistersService {
|
|||||||
currentTask: null,
|
currentTask: null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.warn(`Failed to check status for ${sister.name} (${sister.ip})`);
|
this.logger.warn(`Failed to check status for ${sister.name}`);
|
||||||
return {
|
return {
|
||||||
id: sister.id,
|
id: sister.id,
|
||||||
name: sister.name,
|
name: sister.name,
|
||||||
ip: sister.ip,
|
|
||||||
user: sister.user,
|
user: sister.user,
|
||||||
lxcId: sister.lxcId,
|
lxcId: sister.lxcId,
|
||||||
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class SshService {
|
|||||||
host,
|
host,
|
||||||
username,
|
username,
|
||||||
privateKeyPath,
|
privateKeyPath,
|
||||||
readyTimeout: 5000,
|
readyTimeout: parseInt(process.env.SSH_TIMEOUT_MS ?? '5000', 10),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await ssh.execCommand(command);
|
const result = await ssh.execCommand(command);
|
||||||
@@ -34,8 +34,11 @@ export class SshService {
|
|||||||
code: result.code,
|
code: result.code,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`SSH connection failed to ${host}: ${(error as Error).message}`);
|
// 민감정보 마스킹 — host/user만 노출, 에러 메시지 제한
|
||||||
throw error;
|
const msg = error instanceof Error ? error.message : 'unknown';
|
||||||
|
const safeMsg = msg.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[IP]');
|
||||||
|
this.logger.warn(`SSH failed [${username}@host]: ${safeMsg}`);
|
||||||
|
throw new Error(`SSH connection failed for ${username}`);
|
||||||
} finally {
|
} finally {
|
||||||
ssh.dispose();
|
ssh.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
28
backend/src/tasks/tasks.controller.ts
Normal file
28
backend/src/tasks/tasks.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller, Get, Post, Patch, Param, Body, ParseIntPipe } from '@nestjs/common';
|
||||||
|
import { TasksService, CreateSprintDto, UpdateTaskDto } from './tasks.service';
|
||||||
|
|
||||||
|
@Controller('api')
|
||||||
|
export class TasksController {
|
||||||
|
constructor(private readonly tasksService: TasksService) {}
|
||||||
|
|
||||||
|
@Get('projects/:id/tasks')
|
||||||
|
getTaskLedger(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.tasksService.getTaskLedger(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('projects/:id/sprints')
|
||||||
|
createSprint(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: CreateSprintDto,
|
||||||
|
) {
|
||||||
|
return this.tasksService.createSprint(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('tasks/:id')
|
||||||
|
updateTask(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateTaskDto,
|
||||||
|
) {
|
||||||
|
return this.tasksService.updateTask(id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/tasks/tasks.module.ts
Normal file
12
backend/src/tasks/tasks.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TasksController } from './tasks.controller';
|
||||||
|
import { TasksService } from './tasks.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { ActivityModule } from '../activity/activity.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, ActivityModule],
|
||||||
|
controllers: [TasksController],
|
||||||
|
providers: [TasksService],
|
||||||
|
})
|
||||||
|
export class TasksModule {}
|
||||||
79
backend/src/tasks/tasks.service.spec.ts
Normal file
79
backend/src/tasks/tasks.service.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { TasksService } from './tasks.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('TasksService', () => {
|
||||||
|
let service: TasksService;
|
||||||
|
|
||||||
|
const mockTask = {
|
||||||
|
id: 1, taskId: 'TASK-001', title: 'Test', assignee: 'narang',
|
||||||
|
status: 'pending', iteration: 0, sprintId: 1,
|
||||||
|
createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSprint = {
|
||||||
|
id: 1, projectId: 1, number: 1, name: 'Sprint 001',
|
||||||
|
status: 'in_progress', startedAt: null, completedAt: null,
|
||||||
|
createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
tasks: [mockTask],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
project: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
sprint: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findUnique: jest.fn().mockResolvedValue(mockSprint),
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockActivity = { log: jest.fn().mockResolvedValue({}) };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TasksService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: ActivityService, useValue: mockActivity },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<TasksService>(TasksService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTaskLedger: project 없으면 NotFoundException', async () => {
|
||||||
|
mockPrisma.project.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.getTaskLedger(99)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTaskLedger: sprint + task + progress 반환', async () => {
|
||||||
|
mockPrisma.project.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
sprints: [{ ...mockSprint, tasks: [{ ...mockTask, status: 'done' }, mockTask] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getTaskLedger(1);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].progress).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateTask: 상태 변경 시 ActivityLog 기록', async () => {
|
||||||
|
mockPrisma.task.findUnique.mockResolvedValue({ ...mockTask, status: 'in_progress' });
|
||||||
|
mockPrisma.task.update.mockResolvedValue({ ...mockTask, status: 'done', sprintId: 1 });
|
||||||
|
|
||||||
|
await service.updateTask(1, { status: 'done' });
|
||||||
|
|
||||||
|
expect(mockActivity.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'task_updated' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
111
backend/src/tasks/tasks.service.ts
Normal file
111
backend/src/tasks/tasks.service.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
|
||||||
|
import { IsNumber, IsString, IsOptional, IsIn, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateSprintDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
number!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_STATUSES = ['pending', 'in_progress', 'review', 'done', 'failed', 'blocked', 'escalated'] as const;
|
||||||
|
|
||||||
|
export class UpdateTaskDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(VALID_STATUSES)
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
assignee?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TasksService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly activity: ActivityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getTaskLedger(projectId: number) {
|
||||||
|
const project = await this.prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: {
|
||||||
|
sprints: {
|
||||||
|
include: { tasks: { orderBy: { taskId: 'asc' } } },
|
||||||
|
orderBy: { number: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) throw new NotFoundException(`Project ${projectId} not found`);
|
||||||
|
|
||||||
|
return project.sprints.map((sprint) => ({
|
||||||
|
id: sprint.id,
|
||||||
|
number: sprint.number,
|
||||||
|
name: sprint.name,
|
||||||
|
status: sprint.status,
|
||||||
|
startedAt: sprint.startedAt,
|
||||||
|
completedAt: sprint.completedAt,
|
||||||
|
tasks: sprint.tasks,
|
||||||
|
progress: sprint.tasks.length > 0
|
||||||
|
? Math.round(sprint.tasks.filter((t) => t.status === 'done').length / sprint.tasks.length * 100)
|
||||||
|
: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSprint(projectId: number, dto: CreateSprintDto) {
|
||||||
|
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
|
||||||
|
if (!project) throw new NotFoundException(`Project ${projectId} not found`);
|
||||||
|
|
||||||
|
const sprint = await this.prisma.sprint.create({
|
||||||
|
data: { projectId, number: dto.number, name: dto.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activity.log({
|
||||||
|
projectId,
|
||||||
|
action: 'sprint_created',
|
||||||
|
detail: `Sprint ${dto.number} "${dto.name}" created`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(taskId: number, dto: UpdateTaskDto) {
|
||||||
|
const task = await this.prisma.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task) throw new NotFoundException(`Task ${taskId} not found`);
|
||||||
|
|
||||||
|
const prevStatus = task.status;
|
||||||
|
const updated = await this.prisma.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
iteration: dto.status && dto.status !== prevStatus && prevStatus !== 'pending'
|
||||||
|
? { increment: 1 }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sprint = await this.prisma.sprint.findUnique({
|
||||||
|
where: { id: updated.sprintId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activity.log({
|
||||||
|
projectId: sprint?.projectId,
|
||||||
|
action: 'task_updated',
|
||||||
|
detail: `Task ${updated.taskId} status: ${prevStatus} → ${updated.status}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,13 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import StyledComponentsRegistry from '@/lib/registry';
|
import StyledComponentsRegistry from '@/lib/registry';
|
||||||
import GlobalStyle from '@/styles/GlobalStyle';
|
import GlobalStyle from '@/styles/GlobalStyle';
|
||||||
import Sidebar from '@/components/common/Sidebar';
|
import LayoutShell from '@/components/common/LayoutShell';
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: '하나랑 대시보드',
|
title: '하나랑 대시보드',
|
||||||
description: '4자매 멀티에이전트 파이프라인 관제 대시보드',
|
description: '4자매 멀티에이전트 파이프라인 관제 대시보드',
|
||||||
};
|
};
|
||||||
|
|
||||||
const LayoutRoot = styled.div`
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #0D1117;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MainContent = styled.main`
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 220px;
|
|
||||||
min-height: 100vh;
|
|
||||||
transition: margin-left 0.2s ease;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -32,10 +18,7 @@ export default function RootLayout({
|
|||||||
<body>
|
<body>
|
||||||
<StyledComponentsRegistry>
|
<StyledComponentsRegistry>
|
||||||
<GlobalStyle />
|
<GlobalStyle />
|
||||||
<LayoutRoot>
|
<LayoutShell>{children}</LayoutShell>
|
||||||
<Sidebar />
|
|
||||||
<MainContent>{children}</MainContent>
|
|
||||||
</LayoutRoot>
|
|
||||||
</StyledComponentsRegistry>
|
</StyledComponentsRegistry>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import SisterCard from '@/components/dashboard/SisterCard';
|
import SisterCard from '@/components/dashboard/SisterCard';
|
||||||
|
import ProjectCard from '@/components/projects/ProjectCard';
|
||||||
|
import ActivityFeed from '@/components/dashboard/ActivityFeed';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
||||||
|
|
||||||
interface SisterStatus {
|
interface SisterStatus {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
|
||||||
user: string;
|
user: string;
|
||||||
lxcId: number;
|
lxcId: number;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -19,10 +21,10 @@ interface SisterStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_SISTERS: SisterStatus[] = [
|
const MOCK_SISTERS: SisterStatus[] = [
|
||||||
{ id: 1, name: 'harang', ip: '10.10.10.112', user: 'harang', lxcId: 104, role: 'Orchestrator', status: 'online', lastSeen: new Date().toISOString(), currentTask: 'Sprint 001 기획 중' },
|
{ id: 1, name: 'harang', user: 'harang', lxcId: 104, role: 'Orchestrator', status: 'online', lastSeen: new Date().toISOString(), currentTask: 'Sprint 002 기획 중' },
|
||||||
{ id: 2, name: 'narang', ip: '10.10.10.216', user: 'narang', lxcId: 105, role: 'Generator', status: 'working', lastSeen: new Date().toISOString(), currentTask: 'SPRINT-001 구현 중' },
|
{ id: 2, name: 'narang', user: 'narang', lxcId: 105, role: 'Generator', status: 'working', lastSeen: new Date().toISOString(), currentTask: 'SPRINT-002 구현 중' },
|
||||||
{ id: 3, name: 'darang', ip: '10.10.10.136', user: 'darang', lxcId: 106, role: 'Evaluator', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
|
{ id: 3, name: 'darang', user: 'darang', lxcId: 106, role: 'Evaluator', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
|
||||||
{ id: 4, name: 'erang', ip: '10.10.10.163', user: 'erang', lxcId: 107, role: 'Infra Manager', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
|
{ id: 4, name: 'erang', user: 'erang', lxcId: 107, role: 'Infra Manager', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005';
|
||||||
@@ -30,7 +32,7 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005';
|
|||||||
const PageWrapper = styled.div`
|
const PageWrapper = styled.div`
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0D1117;
|
background: ${theme.colors.bg};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PageHeader = styled.div`
|
const PageHeader = styled.div`
|
||||||
@@ -40,19 +42,19 @@ const PageHeader = styled.div`
|
|||||||
const PageTitle = styled.h1`
|
const PageTitle = styled.h1`
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #E6EDF3;
|
color: ${theme.colors.textPrimary};
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PageSubtitle = styled.p`
|
const PageSubtitle = styled.p`
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #8B949E;
|
color: ${theme.colors.textSecondary};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SectionTitle = styled.h2`
|
const SectionTitle = styled.h2`
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #8B949E;
|
color: ${theme.colors.textSecondary};
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -70,7 +72,6 @@ const SistersGrid = styled.div`
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
> * { flex: 1 1 auto; }
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -78,60 +79,77 @@ const TwoColumnRow = styled.div`
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PlaceholderCard = styled.div`
|
const ProjectsGrid = styled.div`
|
||||||
background: rgba(22, 27, 34, 0.8);
|
display: grid;
|
||||||
backdrop-filter: blur(12px);
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
border: 1px solid rgba(240, 246, 252, 0.1);
|
gap: 16px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Card = styled.div`
|
||||||
|
background: ${theme.colors.cardBg};
|
||||||
|
border: 1px solid ${theme.colors.border};
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
padding: 20px;
|
||||||
min-height: 200px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #8B949E;
|
|
||||||
font-size: 14px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ErrorBanner = styled.div`
|
const ErrorBanner = styled.div`
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: rgba(255, 23, 68, 0.08);
|
background: rgba(255,23,68,0.08);
|
||||||
border: 1px solid rgba(255, 23, 68, 0.3);
|
border: 1px solid rgba(255,23,68,0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #FF1744;
|
color: ${theme.colors.offline};
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const PlaceholderText = styled.div`
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
font-size: 13px;
|
||||||
|
`;
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [sisters, setSisters] = useState<SisterStatus[]>(MOCK_SISTERS);
|
const [sisters, setSisters] = useState<SisterStatus[]>(MOCK_SISTERS);
|
||||||
const [loading, setLoading] = useState(false);
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
|
const [activityItems, setActivityItems] = useState<any[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSisters = async () => {
|
const fetchAll = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/sisters`, { cache: 'no-store' });
|
const [sRes, pRes, aRes] = await Promise.allSettled([
|
||||||
if (!res.ok) throw new Error(`API error ${res.status}`);
|
fetch(`${API_URL}/api/sisters`),
|
||||||
const data = await res.json();
|
fetch(`${API_URL}/api/projects`),
|
||||||
setSisters(data);
|
fetch(`${API_URL}/api/activity?limit=20`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (sRes.status === 'fulfilled' && sRes.value.ok) {
|
||||||
|
setSisters(await sRes.value.json());
|
||||||
|
}
|
||||||
|
if (pRes.status === 'fulfilled' && pRes.value.ok) {
|
||||||
|
setProjects(await pRes.value.json());
|
||||||
|
}
|
||||||
|
if (aRes.status === 'fulfilled' && aRes.value.ok) {
|
||||||
|
const d = await aRes.value.json();
|
||||||
|
setActivityItems(d.items ?? []);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch {
|
||||||
// API 미준비 시 mock 데이터 유지
|
setError('API 연결 실패 — 일부 데이터는 mock으로 표시 중');
|
||||||
setError('API 연결 실패 — mock 데이터로 표시 중');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchSisters();
|
fetchAll();
|
||||||
const interval = setInterval(fetchSisters, 30000);
|
const interval = setInterval(fetchAll, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -154,21 +172,49 @@ export default function DashboardPage() {
|
|||||||
status={sister.status}
|
status={sister.status}
|
||||||
lastSeen={sister.lastSeen}
|
lastSeen={sister.lastSeen}
|
||||||
currentTask={sister.currentTask}
|
currentTask={sister.currentTask}
|
||||||
ip={sister.ip}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SistersGrid>
|
</SistersGrid>
|
||||||
|
|
||||||
<TwoColumnRow>
|
<SectionTitle>진행 중 프로젝트</SectionTitle>
|
||||||
<div>
|
{projects.length > 0 ? (
|
||||||
<SectionTitle>진행 중 프로젝트</SectionTitle>
|
<ProjectsGrid>
|
||||||
<PlaceholderCard>Sprint 002에서 구현 예정</PlaceholderCard>
|
{projects.map((p) => (
|
||||||
</div>
|
<ProjectCard
|
||||||
<div>
|
key={p.id}
|
||||||
<SectionTitle>최근 활동 피드</SectionTitle>
|
id={p.id}
|
||||||
<PlaceholderCard>Sprint 002에서 구현 예정</PlaceholderCard>
|
name={p.name}
|
||||||
</div>
|
description={p.description}
|
||||||
</TwoColumnRow>
|
status={p.status}
|
||||||
|
progress={p.progress}
|
||||||
|
doneTasks={p.doneTasks}
|
||||||
|
totalTasks={p.totalTasks}
|
||||||
|
openPRs={p.openPRs}
|
||||||
|
currentSprint={p.currentSprint}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ProjectsGrid>
|
||||||
|
) : (
|
||||||
|
<TwoColumnRow>
|
||||||
|
<Card>
|
||||||
|
<PlaceholderText>등록된 프로젝트 없음</PlaceholderText>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<SectionTitle>최근 활동 피드</SectionTitle>
|
||||||
|
<ActivityFeed items={activityItems} />
|
||||||
|
</Card>
|
||||||
|
</TwoColumnRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<TwoColumnRow>
|
||||||
|
<div />
|
||||||
|
<Card>
|
||||||
|
<SectionTitle>최근 활동 피드</SectionTitle>
|
||||||
|
<ActivityFeed items={activityItems} />
|
||||||
|
</Card>
|
||||||
|
</TwoColumnRow>
|
||||||
|
)}
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
197
frontend/app/projects/[id]/page.tsx
Normal file
197
frontend/app/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import TabNav from '@/components/common/TabNav';
|
||||||
|
import SprintAccordion from '@/components/projects/SprintAccordion';
|
||||||
|
import TaskTable from '@/components/projects/TaskTable';
|
||||||
|
import ActivityFeed from '@/components/dashboard/ActivityFeed';
|
||||||
|
import ProgressBar from '@/components/common/ProgressBar';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'sprints', label: 'Sprints', icon: '📁' },
|
||||||
|
{ id: 'tasks', label: 'Task Ledger', icon: '📋' },
|
||||||
|
{ id: 'activity', label: '활동 로그', icon: '📝' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Page = styled.div`
|
||||||
|
padding: 32px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: ${theme.colors.bg};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BackLink = styled(Link)`
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
&:hover { color: ${theme.colors.textPrimary}; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Header = styled.div`
|
||||||
|
margin-bottom: 28px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: ${theme.colors.textPrimary};
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GitLink = styled.a`
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${theme.colors.accent};
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid rgba(88,166,255,0.3);
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
&:hover { background: rgba(88,166,255,0.1); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Desc = styled.p`
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
margin-bottom: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProgressRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProgressLabel = styled.span`
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRCount = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${theme.colors.accent};
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(88,166,255,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Card = styled.div`
|
||||||
|
background: ${theme.colors.cardBg};
|
||||||
|
border: 1px solid ${theme.colors.border};
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ErrorBanner = styled.div`
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255,23,68,0.08);
|
||||||
|
border: 1px solid rgba(255,23,68,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${theme.colors.offline};
|
||||||
|
margin-bottom: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function ProjectDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [project, setProject] = useState<any>(null);
|
||||||
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
|
const [activity, setActivity] = useState<any[]>([]);
|
||||||
|
const [tab, setTab] = useState('sprints');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [projRes, tasksRes, actRes] = await Promise.all([
|
||||||
|
fetch(`${API_URL}/api/projects/${id}`),
|
||||||
|
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
||||||
|
fetch(`${API_URL}/api/projects/${id}/activity`),
|
||||||
|
]);
|
||||||
|
if (!projRes.ok) throw new Error(`Project API ${projRes.status}`);
|
||||||
|
setProject(await projRes.json());
|
||||||
|
if (tasksRes.ok) setTasks(await tasksRes.json());
|
||||||
|
if (actRes.ok) {
|
||||||
|
const d = await actRes.json();
|
||||||
|
setActivity(d.items ?? []);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError('API 연결 실패');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const allTasks = project?.sprints?.flatMap((s: any) => s.tasks) ?? [];
|
||||||
|
const totalTasks = allTasks.length;
|
||||||
|
const doneTasks = allTasks.filter((t: any) => t.status === 'done').length;
|
||||||
|
const progress = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<BackLink href="/">‹ 대시보드</BackLink>
|
||||||
|
|
||||||
|
{error && <ErrorBanner>⚠️ {error}</ErrorBanner>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Desc>로딩 중...</Desc>
|
||||||
|
) : project ? (
|
||||||
|
<>
|
||||||
|
<Header>
|
||||||
|
<Title>
|
||||||
|
{project.name}
|
||||||
|
{project.repoUrl && (
|
||||||
|
<GitLink href={project.repoUrl} target="_blank" rel="noopener">
|
||||||
|
Gitea ↗
|
||||||
|
</GitLink>
|
||||||
|
)}
|
||||||
|
{(project.openPRs?.length ?? 0) > 0 && (
|
||||||
|
<PRCount>PR {project.openPRs.length}</PRCount>
|
||||||
|
)}
|
||||||
|
</Title>
|
||||||
|
{project.description && <Desc>{project.description}</Desc>}
|
||||||
|
<ProgressRow>
|
||||||
|
<ProgressBar value={progress} />
|
||||||
|
<ProgressLabel>{progress}% ({doneTasks}/{totalTasks})</ProgressLabel>
|
||||||
|
</ProgressRow>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<TabNav tabs={TABS} active={tab} onChange={setTab} />
|
||||||
|
|
||||||
|
{tab === 'sprints' && (
|
||||||
|
<SprintAccordion sprints={tasks.length > 0 ? tasks : (project.sprints ?? [])} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'tasks' && (
|
||||||
|
<TaskTable tasks={allTasks} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'activity' && (
|
||||||
|
<ActivityFeed items={activity} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Desc>프로젝트를 찾을 수 없음</Desc>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/components/common/LayoutShell.tsx
Normal file
37
frontend/components/common/LayoutShell.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
import { SidebarProvider, useSidebar } from '@/lib/SidebarContext';
|
||||||
|
|
||||||
|
const LayoutRoot = styled.div`
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0d1117;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MainContent = styled.main<{ $collapsed: boolean }>`
|
||||||
|
flex: 1;
|
||||||
|
margin-left: ${({ $collapsed }) => ($collapsed ? '60px' : '220px')};
|
||||||
|
min-height: 100vh;
|
||||||
|
transition: margin-left 0.2s ease;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function Inner({ children }: { children: React.ReactNode }) {
|
||||||
|
const { collapsed } = useSidebar();
|
||||||
|
return (
|
||||||
|
<LayoutRoot>
|
||||||
|
<Sidebar />
|
||||||
|
<MainContent $collapsed={collapsed}>{children}</MainContent>
|
||||||
|
</LayoutRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<Inner>{children}</Inner>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/components/common/ProgressBar.tsx
Normal file
37
frontend/components/common/ProgressBar.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
value: number; // 0-100
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Track = styled.div<{ $size: 'sm' | 'md' }>`
|
||||||
|
width: 100%;
|
||||||
|
height: ${({ $size }) => ($size === 'sm' ? '4px' : '6px')};
|
||||||
|
background: rgba(240, 246, 252, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Fill = styled.div<{ $value: number }>`
|
||||||
|
height: 100%;
|
||||||
|
width: ${({ $value }) => $value}%;
|
||||||
|
background: ${({ $value }) =>
|
||||||
|
$value >= 80 ? theme.colors.online :
|
||||||
|
$value >= 40 ? theme.colors.accent :
|
||||||
|
theme.colors.textSecondary};
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function ProgressBar({ value, size = 'md' }: ProgressBarProps) {
|
||||||
|
return (
|
||||||
|
<Track $size={size}>
|
||||||
|
<Fill $value={Math.min(100, Math.max(0, value))} />
|
||||||
|
</Track>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useSidebar } from '@/lib/SidebarContext';
|
||||||
|
|
||||||
const MENU_ITEMS = [
|
const MENU_ITEMS = [
|
||||||
{ href: '/', icon: '⬛', label: '대시보드' },
|
{ href: '/', icon: '⬛', label: '대시보드' },
|
||||||
@@ -106,7 +107,7 @@ const AdminSection = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const { collapsed, setCollapsed } = useSidebar();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
||||||
|
|
||||||
@@ -17,10 +18,10 @@ const statusLabels: Record<Status, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusColors: Record<Status, string> = {
|
const statusColors: Record<Status, string> = {
|
||||||
online: '#00E676',
|
online: theme.colors.online,
|
||||||
offline: '#FF1744',
|
offline: theme.colors.offline,
|
||||||
working: '#2979FF',
|
working: theme.colors.working,
|
||||||
unknown: '#8B949E',
|
unknown: theme.colors.textSecondary,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BadgeWrapper = styled.span<{ $status: Status }>`
|
const BadgeWrapper = styled.span<{ $status: Status }>`
|
||||||
|
|||||||
56
frontend/components/common/TabNav.tsx
Normal file
56
frontend/components/common/TabNav.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
|
export interface Tab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabNavProps {
|
||||||
|
tabs: Tab[];
|
||||||
|
active: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Nav = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid ${theme.colors.border};
|
||||||
|
margin-bottom: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabBtn = styled.button<{ $active: boolean }>`
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid ${({ $active }) => $active ? theme.colors.accent : 'transparent'};
|
||||||
|
color: ${({ $active }) => $active ? theme.colors.textPrimary : theme.colors.textSecondary};
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: ${({ $active }) => $active ? '600' : '400'};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: ${theme.colors.textPrimary};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function TabNav({ tabs, active, onChange }: TabNavProps) {
|
||||||
|
return (
|
||||||
|
<Nav>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabBtn key={tab.id} $active={active === tab.id} onClick={() => onChange(tab.id)}>
|
||||||
|
{tab.icon && <span>{tab.icon}</span>}
|
||||||
|
{tab.label}
|
||||||
|
</TabBtn>
|
||||||
|
))}
|
||||||
|
</Nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/components/common/TaskBadge.tsx
Normal file
37
frontend/components/common/TaskBadge.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
|
type TaskStatus = 'pending' | 'in_progress' | 'review' | 'done' | 'failed' | 'blocked' | 'escalated';
|
||||||
|
|
||||||
|
interface TaskBadgeProps {
|
||||||
|
status: TaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<TaskStatus, { label: string; color: string; bg: string }> = {
|
||||||
|
pending: { label: '대기', color: theme.colors.textSecondary, bg: 'rgba(139,148,158,0.1)' },
|
||||||
|
in_progress: { label: '진행중', color: theme.colors.working, bg: 'rgba(41,121,255,0.12)' },
|
||||||
|
review: { label: '리뷰', color: '#FF9800', bg: 'rgba(255,152,0,0.12)' },
|
||||||
|
done: { label: '완료', color: theme.colors.online, bg: 'rgba(0,230,118,0.12)' },
|
||||||
|
failed: { label: '실패', color: theme.colors.offline, bg: 'rgba(255,23,68,0.12)' },
|
||||||
|
blocked: { label: '블로킹', color: '#FF5722', bg: 'rgba(255,87,34,0.12)' },
|
||||||
|
escalated: { label: '에스컬레이션', color: '#E040FB', bg: 'rgba(224,64,251,0.12)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const Badge = styled.span<{ $color: string; $bg: string }>`
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${({ $color }) => $color};
|
||||||
|
background: ${({ $bg }) => $bg};
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function TaskBadge({ status }: TaskBadgeProps) {
|
||||||
|
const cfg = statusConfig[status] ?? statusConfig.pending;
|
||||||
|
return <Badge $color={cfg.color} $bg={cfg.bg}>{cfg.label}</Badge>;
|
||||||
|
}
|
||||||
115
frontend/components/dashboard/ActivityFeed.tsx
Normal file
115
frontend/components/dashboard/ActivityFeed.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
id: number;
|
||||||
|
action: string;
|
||||||
|
detail: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
sister?: { name: string } | null;
|
||||||
|
project?: { name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionEmojis: Record<string, string> = {
|
||||||
|
sprint_created: '📁',
|
||||||
|
task_updated: '✏️',
|
||||||
|
deploy_complete: '🚀',
|
||||||
|
ssh_check: '🔍',
|
||||||
|
pr_opened: '📋',
|
||||||
|
pr_merged: '✅',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const min = Math.floor(diff / 60000);
|
||||||
|
if (min < 1) return '방금';
|
||||||
|
if (min < 60) return `${min}분 전`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `${hr}시간 전`;
|
||||||
|
return `${Math.floor(hr / 24)}일 전`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const List = styled.ul`
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Item = styled.li`
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid ${theme.colors.border};
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Emoji = styled.span`
|
||||||
|
font-size: 15px;
|
||||||
|
margin-top: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Body = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Action = styled.div`
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${theme.colors.textPrimary};
|
||||||
|
margin-bottom: 2px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Detail = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Time = styled.span`
|
||||||
|
font-size: 11px;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Empty = styled.div`
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
font-size: 13px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function ActivityFeed({ items }: { items: ActivityItem[] }) {
|
||||||
|
if (!items.length) return <Empty>활동 기록 없음</Empty>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Item key={item.id}>
|
||||||
|
<Emoji>{actionEmojis[item.action] ?? '📝'}</Emoji>
|
||||||
|
<Body>
|
||||||
|
<Action>
|
||||||
|
{item.sister && <strong>{item.sister.name}</strong>}
|
||||||
|
{item.sister && ' · '}
|
||||||
|
{item.action.replace(/_/g, ' ')}
|
||||||
|
</Action>
|
||||||
|
{item.detail && <Detail>{item.detail}</Detail>}
|
||||||
|
</Body>
|
||||||
|
<Time>{formatTime(item.createdAt)}</Time>
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import StatusBadge from '../common/StatusBadge';
|
import StatusBadge from '../common/StatusBadge';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
||||||
|
|
||||||
@@ -12,14 +13,13 @@ interface SisterCardProps {
|
|||||||
status: Status;
|
status: Status;
|
||||||
lastSeen: string | null;
|
lastSeen: string | null;
|
||||||
currentTask: string | null;
|
currentTask: string | null;
|
||||||
ip: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBorderColors: Record<Status, string> = {
|
const statusBorderColors: Record<Status, string> = {
|
||||||
online: '#00E676',
|
online: theme.colors.online,
|
||||||
offline: '#FF1744',
|
offline: theme.colors.offline,
|
||||||
working: '#2979FF',
|
working: theme.colors.working,
|
||||||
unknown: '#8B949E',
|
unknown: theme.colors.textSecondary,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sisterEmojis: Record<string, string> = {
|
const sisterEmojis: Record<string, string> = {
|
||||||
@@ -130,7 +130,6 @@ export default function SisterCard({
|
|||||||
status,
|
status,
|
||||||
lastSeen,
|
lastSeen,
|
||||||
currentTask,
|
currentTask,
|
||||||
ip,
|
|
||||||
}: SisterCardProps) {
|
}: SisterCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card $status={status}>
|
<Card $status={status}>
|
||||||
@@ -147,9 +146,6 @@ export default function SisterCard({
|
|||||||
<MetaRow>
|
<MetaRow>
|
||||||
마지막 활동: {formatLastSeen(lastSeen)}
|
마지막 활동: {formatLastSeen(lastSeen)}
|
||||||
</MetaRow>
|
</MetaRow>
|
||||||
<MetaRow>
|
|
||||||
<IpBadge>{ip}</IpBadge>
|
|
||||||
</MetaRow>
|
|
||||||
{currentTask && <CurrentTask>📌 {currentTask}</CurrentTask>}
|
{currentTask && <CurrentTask>📌 {currentTask}</CurrentTask>}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
115
frontend/components/projects/ProjectCard.tsx
Normal file
115
frontend/components/projects/ProjectCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import ProgressBar from '../common/ProgressBar';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
doneTasks: number;
|
||||||
|
totalTasks: number;
|
||||||
|
openPRs: number;
|
||||||
|
currentSprint: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card = styled(Link)`
|
||||||
|
display: block;
|
||||||
|
background: ${theme.colors.cardBg};
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid ${theme.colors.border};
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
border-color: rgba(88,166,255,0.3);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Header = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Name = styled.span`
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${theme.colors.textPrimary};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Meta = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
margin-bottom: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Stats = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Stat = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProgressLabel = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProgressText = styled.span`
|
||||||
|
font-size: 11px;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRBadge = styled.span<{ $count: number }>`
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
|
||||||
|
color: ${({ $count }) => $count > 0 ? theme.colors.accent : theme.colors.textSecondary};
|
||||||
|
font-weight: 600;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function ProjectCard({
|
||||||
|
id, name, description, progress, doneTasks, totalTasks, openPRs, currentSprint,
|
||||||
|
}: ProjectCardProps) {
|
||||||
|
return (
|
||||||
|
<Card href={`/projects/${id}`}>
|
||||||
|
<Header>
|
||||||
|
<Name>{name}</Name>
|
||||||
|
<PRBadge $count={openPRs}>PR {openPRs}</PRBadge>
|
||||||
|
</Header>
|
||||||
|
{description && <Meta>{description}</Meta>}
|
||||||
|
{currentSprint && (
|
||||||
|
<Meta>📌 {currentSprint}</Meta>
|
||||||
|
)}
|
||||||
|
<ProgressLabel>
|
||||||
|
<ProgressText>진행률</ProgressText>
|
||||||
|
<ProgressText>{progress}% ({doneTasks}/{totalTasks})</ProgressText>
|
||||||
|
</ProgressLabel>
|
||||||
|
<ProgressBar value={progress} size="sm" />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
frontend/components/projects/SprintAccordion.tsx
Normal file
201
frontend/components/projects/SprintAccordion.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import TaskBadge from '../common/TaskBadge';
|
||||||
|
import ProgressBar from '../common/ProgressBar';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: number;
|
||||||
|
taskId: string;
|
||||||
|
title: string;
|
||||||
|
assignee: string;
|
||||||
|
status: string;
|
||||||
|
iteration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sprint {
|
||||||
|
id: number;
|
||||||
|
number: number;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
tasks: Task[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sprintStatusIcon: Record<string, string> = {
|
||||||
|
pending: '⏳',
|
||||||
|
in_progress: '🔄',
|
||||||
|
review: '🔍',
|
||||||
|
done: '✅',
|
||||||
|
failed: '❌',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sprintStatusColor: Record<string, string> = {
|
||||||
|
pending: theme.colors.textSecondary,
|
||||||
|
in_progress: theme.colors.working,
|
||||||
|
review: '#FF9800',
|
||||||
|
done: theme.colors.online,
|
||||||
|
failed: theme.colors.offline,
|
||||||
|
};
|
||||||
|
|
||||||
|
const assigneeEmojis: Record<string, string> = {
|
||||||
|
harang: '🦊', narang: '🦊', darang: '🐱', erang: '🐺',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AccordionItem = styled.div<{ $open: boolean }>`
|
||||||
|
background: ${theme.colors.cardBg};
|
||||||
|
border: 1px solid ${theme.colors.border};
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
${({ $open }) => $open && `border-color: rgba(88,166,255,0.3);`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Header = styled.button<{ $statusColor: string }>`
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: ${theme.colors.textPrimary};
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(240, 246, 252, 0.04);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SprintNum = styled.span<{ $color: string }>`
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: ${({ $color }) => $color};
|
||||||
|
min-width: 60px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SprintName = styled.span`
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProgressWrap = styled.div`
|
||||||
|
width: 80px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Chevron = styled.span<{ $open: boolean }>`
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
transform: ${({ $open }) => $open ? 'rotate(90deg)' : 'none'};
|
||||||
|
transition: transform 0.2s;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Body = styled.div<{ $open: boolean }>`
|
||||||
|
display: ${({ $open }) => $open ? 'block' : 'none'};
|
||||||
|
border-top: 1px solid ${theme.colors.border};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TaskRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid ${theme.colors.border};
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
&:hover { background: rgba(240,246,252,0.03); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TaskId = styled.code`
|
||||||
|
font-size: 11px;
|
||||||
|
color: ${theme.colors.accent};
|
||||||
|
min-width: 70px;
|
||||||
|
font-family: monospace;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TaskTitle = styled.span`
|
||||||
|
flex: 1;
|
||||||
|
color: ${theme.colors.textPrimary};
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Assignee = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
min-width: 60px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Iteration = styled.span<{ $count: number }>`
|
||||||
|
font-size: 11px;
|
||||||
|
color: ${({ $count }) => $count > 0 ? '#FF9800' : theme.colors.textSecondary};
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: right;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function SprintAccordion({ sprints }: { sprints: Sprint[] }) {
|
||||||
|
const [openIds, setOpenIds] = useState<Set<number>>(
|
||||||
|
() => new Set(sprints.filter((s) => s.status === 'in_progress').map((s) => s.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = (id: number) => {
|
||||||
|
setOpenIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
{sprints.map((sprint) => {
|
||||||
|
const open = openIds.has(sprint.id);
|
||||||
|
const color = sprintStatusColor[sprint.status] ?? theme.colors.textSecondary;
|
||||||
|
return (
|
||||||
|
<AccordionItem key={sprint.id} $open={open}>
|
||||||
|
<Header $statusColor={color} onClick={() => toggle(sprint.id)}>
|
||||||
|
<SprintNum $color={color}>
|
||||||
|
{sprintStatusIcon[sprint.status] ?? '⏳'} #{sprint.number}
|
||||||
|
</SprintNum>
|
||||||
|
<SprintName>{sprint.name}</SprintName>
|
||||||
|
<ProgressWrap>
|
||||||
|
<ProgressBar value={sprint.progress} size="sm" />
|
||||||
|
</ProgressWrap>
|
||||||
|
<Chevron $open={open}>›</Chevron>
|
||||||
|
</Header>
|
||||||
|
<Body $open={open}>
|
||||||
|
{sprint.tasks.length === 0 ? (
|
||||||
|
<TaskRow><TaskTitle style={{ color: theme.colors.textSecondary }}>태스크 없음</TaskTitle></TaskRow>
|
||||||
|
) : (
|
||||||
|
sprint.tasks.map((task) => (
|
||||||
|
<TaskRow key={task.id}>
|
||||||
|
<TaskId>{task.taskId}</TaskId>
|
||||||
|
<TaskTitle>{task.title}</TaskTitle>
|
||||||
|
<Assignee>{assigneeEmojis[task.assignee] ?? '🤖'} {task.assignee}</Assignee>
|
||||||
|
<TaskBadge status={task.status as any} />
|
||||||
|
<Iteration $count={task.iteration}>
|
||||||
|
{task.iteration > 0 ? `×${task.iteration}` : ''}
|
||||||
|
</Iteration>
|
||||||
|
</TaskRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Body>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
frontend/components/projects/TaskTable.tsx
Normal file
129
frontend/components/projects/TaskTable.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import TaskBadge from '../common/TaskBadge';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: number;
|
||||||
|
taskId: string;
|
||||||
|
title: string;
|
||||||
|
assignee: string;
|
||||||
|
status: string;
|
||||||
|
iteration: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_STATUSES = ['all', 'pending', 'in_progress', 'review', 'done', 'failed', 'blocked', 'escalated'];
|
||||||
|
|
||||||
|
const Wrapper = styled.div``;
|
||||||
|
|
||||||
|
const Filters = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FilterBtn = styled.button<{ $active: boolean }>`
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid ${({ $active }) => $active ? theme.colors.accent : theme.colors.border};
|
||||||
|
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
|
||||||
|
color: ${({ $active }) => $active ? theme.colors.accent : theme.colors.textSecondary};
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover { border-color: ${theme.colors.accent}; color: ${theme.colors.accent}; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Table = styled.table`
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Th = styled.th`
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
border-bottom: 1px solid ${theme.colors.border};
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Td = styled.td`
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid ${theme.colors.border};
|
||||||
|
color: ${theme.colors.textPrimary};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Tr = styled.tr`
|
||||||
|
&:last-child td { border-bottom: none; }
|
||||||
|
&:hover td { background: rgba(240,246,252,0.03); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TaskIdCell = styled.code`
|
||||||
|
color: ${theme.colors.accent};
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const IterationCell = styled.span<{ $count: number }>`
|
||||||
|
color: ${({ $count }) => $count > 0 ? '#FF9800' : theme.colors.textSecondary};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Empty = styled.div`
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: ${theme.colors.textSecondary};
|
||||||
|
font-size: 13px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function TaskTable({ tasks }: { tasks: Task[] }) {
|
||||||
|
const [filter, setFilter] = useState('all');
|
||||||
|
|
||||||
|
const filtered = filter === 'all' ? tasks : tasks.filter((t) => t.status === filter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Filters>
|
||||||
|
{ALL_STATUSES.map((s) => (
|
||||||
|
<FilterBtn key={s} $active={filter === s} onClick={() => setFilter(s)}>
|
||||||
|
{s === 'all' ? '전체' : s}
|
||||||
|
</FilterBtn>
|
||||||
|
))}
|
||||||
|
</Filters>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<Empty>해당 상태의 태스크 없음</Empty>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<Th>ID</Th>
|
||||||
|
<Th>제목</Th>
|
||||||
|
<Th>담당</Th>
|
||||||
|
<Th>상태</Th>
|
||||||
|
<Th>반복</Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((task) => (
|
||||||
|
<Tr key={task.id}>
|
||||||
|
<Td><TaskIdCell>{task.taskId}</TaskIdCell></Td>
|
||||||
|
<Td>{task.title}</Td>
|
||||||
|
<Td>{task.assignee}</Td>
|
||||||
|
<Td><TaskBadge status={task.status as any} /></Td>
|
||||||
|
<Td><IterationCell $count={task.iteration}>{task.iteration || '-'}</IterationCell></Td>
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/lib/SidebarContext.tsx
Normal file
26
frontend/lib/SidebarContext.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState } from 'react';
|
||||||
|
|
||||||
|
interface SidebarContextValue {
|
||||||
|
collapsed: boolean;
|
||||||
|
setCollapsed: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = createContext<SidebarContextValue>({
|
||||||
|
collapsed: false,
|
||||||
|
setCollapsed: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={{ collapsed, setCollapsed }}>
|
||||||
|
{children}
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebar() {
|
||||||
|
return useContext(SidebarContext);
|
||||||
|
}
|
||||||
1179
frontend/package-lock.json
generated
1179
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"styled-components": "^6.3.12"
|
"styled-components": "^6.3.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user