design: Mobile Publish
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Local
|
||||||
|
*.local
|
||||||
105
DEPLOY.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# Merk Korea PDA 배포 가이드
|
||||||
|
|
||||||
|
## 1. 로컬 빌드 및 실행
|
||||||
|
|
||||||
|
### 빌드
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드 결과물은 `dist/` 폴더에 생성됩니다.
|
||||||
|
|
||||||
|
### 로컬에서 빌드 결과 확인
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
http://localhost:4173 에서 확인 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Docker로 배포
|
||||||
|
|
||||||
|
### 이미지 빌드
|
||||||
|
```bash
|
||||||
|
docker build -t merk-korea-pda .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컨테이너 실행
|
||||||
|
```bash
|
||||||
|
docker run -d -p 80:80 --name merk-pda merk-korea-pda
|
||||||
|
```
|
||||||
|
|
||||||
|
http://localhost 에서 확인 가능
|
||||||
|
|
||||||
|
### 컨테이너 중지/삭제
|
||||||
|
```bash
|
||||||
|
docker stop merk-pda
|
||||||
|
docker rm merk-pda
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Docker Compose (선택)
|
||||||
|
|
||||||
|
`docker-compose.yml` 파일 생성:
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
실행:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 정적 파일 직접 배포
|
||||||
|
|
||||||
|
`dist/` 폴더를 웹 서버에 업로드하면 됩니다.
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
React Router(SPA)를 사용하므로, 모든 경로를 `index.html`로 리다이렉트 설정 필요:
|
||||||
|
|
||||||
|
**Nginx 예시:**
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apache 예시 (.htaccess):**
|
||||||
|
```apache
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
RewriteRule ^index\.html$ - [L]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule . /index.html [L]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 포트 변경
|
||||||
|
|
||||||
|
### Docker 포트 변경
|
||||||
|
```bash
|
||||||
|
docker run -d -p 3000:80 --name merk-pda merk-korea-pda
|
||||||
|
```
|
||||||
|
→ http://localhost:3000 에서 접근
|
||||||
|
|
||||||
|
### Vite 개발 서버 포트 변경
|
||||||
|
`vite.config.ts`에서 설정:
|
||||||
|
```ts
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
port: 3000
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
23
Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# SPA 라우팅을 위한 nginx 설정 복사
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 빌드된 정적 파일 복사
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
37
PAGES.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Merk Korea PDA 페이지 목록
|
||||||
|
|
||||||
|
## 기본
|
||||||
|
| 페이지 | URL |
|
||||||
|
|--------|-----|
|
||||||
|
| 로그인 | http://localhost:5173/ |
|
||||||
|
| 대시보드 | http://localhost:5173/dashboard |
|
||||||
|
|
||||||
|
## 출입기록 검색
|
||||||
|
| 페이지 | URL |
|
||||||
|
|--------|-----|
|
||||||
|
| 시설별 현황 | http://localhost:5173/facility-status |
|
||||||
|
| 시설 상세 | http://localhost:5173/facility-status/{시설명} |
|
||||||
|
| 출입 로그 | http://localhost:5173/facility-status/{시설명}/log/{위치명} |
|
||||||
|
| 층별 로그 | http://localhost:5173/facility-status/{시설명}/floor-log/{층이름} |
|
||||||
|
| 전체 로그 | http://localhost:5173/facility-status/{시설명}/all-log |
|
||||||
|
|
||||||
|
## 비상대응 관리
|
||||||
|
| 페이지 | URL |
|
||||||
|
|--------|-----|
|
||||||
|
| 비상경보 발생 | http://localhost:5173/emergency-alert |
|
||||||
|
| 대피소 현황 | http://localhost:5173/emergency-fire |
|
||||||
|
| 대피자 목록 | http://localhost:5173/emergency-fire/evacuee-list |
|
||||||
|
| 미대피자 목록 | http://localhost:5173/emergency-fire/non-evacuee-list |
|
||||||
|
| 인원태깅 (QR스캔) | http://localhost:5173/qr-scan |
|
||||||
|
| QR스캔 완료 | http://localhost:5173/qr-scan/complete |
|
||||||
|
|
||||||
|
## 태그설정
|
||||||
|
| 페이지 | URL |
|
||||||
|
|--------|-----|
|
||||||
|
| 태그 배터리 | http://localhost:5173/tag-battery |
|
||||||
|
| 태그 배터리 목록 | http://localhost:5173/tag-battery/{type} |
|
||||||
|
|
||||||
|
## 관리자설정
|
||||||
|
| 페이지 | URL |
|
||||||
|
|--------|-----|
|
||||||
|
| 내 정보 설정 | http://localhost:5173/my-info |
|
||||||
BIN
app-design/MK-001.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
app-design/MK-001_CASE.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
app-design/MK-001_MENU.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
app-design/MK-002.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
app-design/MK-003-1.png
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
app-design/MK-003-2.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
app-design/MK-003-3.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
app-design/MK-003-4.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
app-design/MK-003.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
app-design/MK-005.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
app-design/MK-006-1.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
app-design/MK-006-2.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
app-design/MK-006.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
app-design/MK-007-1.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
app-design/MK-007-2.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
app-design/MK-010-1.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
app-design/MK-010-2.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
app-design/MK-010-3.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
app-design/MK-011.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>머크코리아 작업자 위치관제 시스템</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
nginx.conf
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# gzip 압축
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# SPA 라우팅: 모든 경로를 index.html로
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 정적 파일 캐싱
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
1768
package-lock.json
generated
Normal file
24
package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "merk-korea-pda",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/App.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
import FacilityStatusPage from './pages/FacilityStatusPage'
|
||||||
|
import FacilityDetailPage from './pages/FacilityDetailPage'
|
||||||
|
import AccessLogPage from './pages/AccessLogPage'
|
||||||
|
import FloorLogPage from './pages/FloorLogPage'
|
||||||
|
import AllLogPage from './pages/AllLogPage'
|
||||||
|
import EmergencyAlertPage from './pages/EmergencyAlertPage'
|
||||||
|
import EmergencyFirePage from './pages/EmergencyFirePage'
|
||||||
|
import EvacueeListPage from './pages/EvacueeListPage'
|
||||||
|
import NonEvacueeListPage from './pages/NonEvacueeListPage'
|
||||||
|
import QRScanPage from './pages/QRScanPage'
|
||||||
|
import QRScanCompletePage from './pages/QRScanCompletePage'
|
||||||
|
import TagBatteryPage from './pages/TagBatteryPage'
|
||||||
|
import TagBatteryListPage from './pages/TagBatteryListPage'
|
||||||
|
import MyInfoPage from './pages/MyInfoPage'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<LoginPage />} />
|
||||||
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="/facility-status" element={<FacilityStatusPage />} />
|
||||||
|
<Route path="/facility-status/:facilityName" element={<FacilityDetailPage />} />
|
||||||
|
<Route path="/facility-status/:facilityName/log/:locationName" element={<AccessLogPage />} />
|
||||||
|
<Route path="/facility-status/:facilityName/floor-log/:floorName" element={<FloorLogPage />} />
|
||||||
|
<Route path="/facility-status/:facilityName/all-log" element={<AllLogPage />} />
|
||||||
|
<Route path="/emergency-alert" element={<EmergencyAlertPage />} />
|
||||||
|
<Route path="/emergency-fire" element={<EmergencyFirePage />} />
|
||||||
|
<Route path="/emergency-fire/evacuee-list" element={<EvacueeListPage />} />
|
||||||
|
<Route path="/emergency-fire/non-evacuee-list" element={<NonEvacueeListPage />} />
|
||||||
|
<Route path="/qr-scan" element={<QRScanPage />} />
|
||||||
|
<Route path="/qr-scan/complete" element={<QRScanCompletePage />} />
|
||||||
|
<Route path="/tag-battery" element={<TagBatteryPage />} />
|
||||||
|
<Route path="/tag-battery/:type" element={<TagBatteryListPage />} />
|
||||||
|
<Route path="/my-info" element={<MyInfoPage />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
6
src/assets/arrow-up-right.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="ArrowDownRight">
|
||||||
|
<rect x="0.5" y="0.5" width="29" height="29" rx="14.5" stroke="var(--stroke-0, white)" stroke-opacity="0.16"/>
|
||||||
|
<path id="Vector" d="M16.2572 10.6004L20.3426 14.6857C20.4259 14.7691 20.4728 14.8821 20.4728 15C20.4728 15.1179 20.4259 15.2309 20.3426 15.3143L16.2572 19.3996C16.1739 19.483 16.0608 19.5298 15.943 19.5298C15.8251 19.5298 15.712 19.483 15.6287 19.3996C15.5453 19.3163 15.4985 19.2033 15.4985 19.0854C15.4985 18.9675 15.5453 18.8545 15.6287 18.7711L18.9555 15.4443L9.97201 15.4447C9.85408 15.4447 9.74097 15.3978 9.65758 15.3144C9.57419 15.231 9.52734 15.1179 9.52734 15C9.52734 14.8821 9.57419 14.769 9.65758 14.6856C9.74097 14.6022 9.85408 14.5553 9.97201 14.5553L18.9555 14.5557L15.6287 11.2289C15.5453 11.1455 15.4985 11.0325 15.4985 10.9146C15.4985 10.7967 15.5453 10.6837 15.6287 10.6004C15.712 10.517 15.8251 10.4702 15.943 10.4702C16.0608 10.4702 16.1739 10.517 16.2572 10.6004Z" fill="var(--fill-0, white)" stroke="var(--stroke-0, white)" stroke-width="0.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
24
src/assets/battery_high.svg
Normal file
|
After Width: | Height: | Size: 188 KiB |
18
src/assets/battery_low.svg
Normal file
|
After Width: | Height: | Size: 183 KiB |
16
src/assets/bg-ellipse-blur.svg
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 826 809" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Ellipse 2327" filter="url(#filter0_f_2036_2960)">
|
||||||
|
<ellipse cx="413" cy="404.5" rx="213" ry="204.5" fill="url(#paint0_linear_2036_2960)" fill-opacity="0.8"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_f_2036_2960" x="0" y="0" width="826" height="809" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_2036_2960"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2036_2960" x1="418.367" y1="152.981" x2="-4.8013" y2="541.959" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#7798CE"/>
|
||||||
|
<stop offset="0.636817" stop-color="#4F48B0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 951 B |
3
src/assets/check-icon.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 38 29" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path id="Vector" d="M1.28594 19.0305C0.878247 18.6261 0.554845 18.146 0.334204 17.6177C0.113562 17.0893 8.59147e-09 16.523 0 15.9511C-8.59147e-09 15.3793 0.113562 14.813 0.334204 14.2846C0.554845 13.7563 0.878247 13.2762 1.28594 12.8718C1.69363 12.4675 2.17763 12.1467 2.71031 11.9278C3.24298 11.709 3.8139 11.5963 4.39046 11.5963C4.96703 11.5963 5.53795 11.709 6.07062 11.9278C6.6033 12.1467 7.0873 12.4675 7.49499 12.8718L13.1525 18.4907L30.505 1.2755C31.3284 0.458811 32.4451 -8.60518e-09 33.6095 0C34.774 8.60518e-09 35.8907 0.458811 36.7141 1.2755C37.5374 2.09218 38 3.19984 38 4.35481C38 5.50978 37.5374 6.61744 36.7141 7.43412L16.2607 27.7214C15.8535 28.1267 15.3697 28.4482 14.8369 28.6676C14.3042 28.8871 13.733 29 13.1562 29C12.5793 29 12.0082 28.8871 11.4754 28.6676C10.9427 28.4482 10.4588 28.1267 10.0517 27.7214L1.28594 19.0305Z" fill="var(--fill-0, white)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
5
src/assets/close-icon.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="ImageAssets_closed">
|
||||||
|
<path id="Union" d="M27.5444 11.2374C27.8568 10.925 28.3629 10.925 28.6753 11.2374C28.9876 11.5498 28.9876 12.0558 28.6753 12.3682L21.0845 19.9591L28.6753 27.5499C28.9876 27.8623 28.9876 28.3683 28.6753 28.6807C28.3629 28.9931 27.8568 28.9931 27.5444 28.6807L19.9536 21.0899L12.3628 28.6807C12.0504 28.9931 11.5443 28.9931 11.2319 28.6807C10.9195 28.3683 10.9195 27.8623 11.2319 27.5499L18.8227 19.9591L11.2319 12.3682C10.9195 12.0558 10.9195 11.5498 11.2319 11.2374C11.5443 10.925 12.0504 10.925 12.3628 11.2374L19.9536 18.8282L27.5444 11.2374Z" fill="var(--fill-0, black)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 787 B |
BIN
src/assets/emergency-bell.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
3
src/assets/logo.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
18
src/assets/mk005-bg.svg
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 826 1712" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="bg">
|
||||||
|
<g id="Ellipse 2327" filter="url(#filter0_f_2037_3041)">
|
||||||
|
<ellipse cx="413" cy="404.5" rx="213" ry="204.5" fill="url(#paint0_linear_2037_3041)" fill-opacity="0.8"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_f_2037_3041" x="0" y="0" width="826" height="809" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_2037_3041"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2037_3041" x1="418.367" y1="152.981" x2="-4.8013" y2="541.959" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#7798CE"/>
|
||||||
|
<stop offset="0.636817" stop-color="#4F48B0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 969 B |
BIN
src/assets/qr-illustration.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
16
src/assets/red-ellipse-bg.svg
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 815 797" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Ellipse 2327" filter="url(#filter0_f_2037_3073)">
|
||||||
|
<ellipse cx="407.5" cy="398.5" rx="207.5" ry="198.5" fill="url(#paint0_linear_2037_3073)"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_f_2037_3073" x="0" y="0" width="815" height="797" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_2037_3073"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2037_3073" x1="412.728" y1="154.361" x2="1.85356" y2="533.407" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FF2215"/>
|
||||||
|
<stop offset="0.636817" stop-color="#B10101"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 936 B |
43
src/components/common/Button.module.css
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/* Total Exits Container / Button */
|
||||||
|
.button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0px;
|
||||||
|
gap: 16px;
|
||||||
|
height: 60px;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullWidth {
|
||||||
|
width: 320px;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonText {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
background: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
22
src/components/common/Button.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ButtonHTMLAttributes } from 'react'
|
||||||
|
import styles from './Button.module.css'
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
fullWidth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
children,
|
||||||
|
fullWidth = false,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${styles.button} ${fullWidth ? styles.fullWidth : ''} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className={styles.buttonText}>{children}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
src/components/common/Input.module.css
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
/* Input Container */
|
||||||
|
.inputContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0px;
|
||||||
|
gap: 16px;
|
||||||
|
width: 320px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label */
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labelText {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Wrapper */
|
||||||
|
.inputWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0px;
|
||||||
|
gap: 8px;
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Field */
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 22px 16px;
|
||||||
|
gap: 10px;
|
||||||
|
width: 320px;
|
||||||
|
height: 64px;
|
||||||
|
background: #1C2542;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex: none;
|
||||||
|
order: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
/* Text style */
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 27px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: #D1D9FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password Toggle Button */
|
||||||
|
.toggleButton {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrapper .input {
|
||||||
|
padding-right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Text */
|
||||||
|
.errorText {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 27px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #BF151B;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error state - add margin for error message space */
|
||||||
|
.hasError {
|
||||||
|
margin-bottom: 35px;
|
||||||
|
}
|
||||||
71
src/components/common/Input.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { useState, InputHTMLAttributes } from 'react'
|
||||||
|
import styles from './Input.module.css'
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label: string
|
||||||
|
error?: string
|
||||||
|
showPasswordToggle?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Input({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
type = 'text',
|
||||||
|
showPasswordToggle = false,
|
||||||
|
...props
|
||||||
|
}: InputProps) {
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
|
const inputType = showPasswordToggle
|
||||||
|
? (showPassword ? 'text' : 'password')
|
||||||
|
: type
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.inputContainer} ${error ? styles.hasError : ''}`}>
|
||||||
|
<div className={styles.label}>
|
||||||
|
<span className={styles.labelText}>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<input
|
||||||
|
type={inputType}
|
||||||
|
className={styles.input}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{showPasswordToggle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.toggleButton}
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <span className={styles.errorText}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
|
||||||
|
stroke="#D1D9FF"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="3"
|
||||||
|
stroke="#D1D9FF"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
src/components/common/Logo.module.css
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/* Menu Icon / Logo */
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0px;
|
||||||
|
gap: 5.33px;
|
||||||
|
width: 104px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoImage {
|
||||||
|
width: 99px;
|
||||||
|
height: 16px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subText {
|
||||||
|
width: 85px;
|
||||||
|
height: 12px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
.light {
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .subText {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .subText {
|
||||||
|
color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
22
src/components/common/Logo.tsx
Normal file
32
src/components/dashboard/BatteryHealthCard.module.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
.card {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: #121628;
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
40
src/components/dashboard/BatteryHealthCard.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import styles from './BatteryHealthCard.module.css'
|
||||||
|
|
||||||
|
interface BatteryHealthCardProps {
|
||||||
|
level: 'high' | 'low'
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BatteryHealthCard({ level, value }: BatteryHealthCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<BatteryIcon level={level} />
|
||||||
|
<div className={styles.content}>
|
||||||
|
<span className={styles.value}>{value.toLocaleString()}</span>
|
||||||
|
<span className={styles.label}>{level === 'high' ? 'High' : 'Low'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatteryIcon({ level }: { level: 'high' | 'low' }) {
|
||||||
|
const fillColor = level === 'high' ? '#4CAF50' : '#FF5252'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width="22" height="36" viewBox="0 0 22 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{/* Battery body */}
|
||||||
|
<rect x="1" y="4" width="20" height="31" rx="3" stroke="white" strokeWidth="1.5" fill="none"/>
|
||||||
|
{/* Battery cap */}
|
||||||
|
<rect x="7" y="1" width="8" height="3" rx="1" fill="white"/>
|
||||||
|
{/* Battery fill */}
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y={level === 'high' ? '7' : '22'}
|
||||||
|
width="16"
|
||||||
|
height={level === 'high' ? '26' : '11'}
|
||||||
|
rx="1.5"
|
||||||
|
fill={fillColor}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
src/components/dashboard/BuildingCard.module.css
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 0 12px;
|
||||||
|
background: #121628;
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(12.66px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
background: #1a1f38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workerCount {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 16px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorStatus {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background: #0B0C12;
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 36px;
|
||||||
|
width: calc(100% - 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorLabel {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #748CFF;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorValues {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorItem {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorType {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888888;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorValue {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.26px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorValue.dimmed {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorValue.broken {
|
||||||
|
color: #FF020B;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
59
src/components/dashboard/BuildingCard.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import styles from './BuildingCard.module.css'
|
||||||
|
|
||||||
|
interface BuildingCardProps {
|
||||||
|
name: string
|
||||||
|
workers: number
|
||||||
|
anchorNormal: number
|
||||||
|
anchorBroken: number
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuildingCard({
|
||||||
|
name,
|
||||||
|
workers,
|
||||||
|
anchorNormal,
|
||||||
|
anchorBroken,
|
||||||
|
onClick
|
||||||
|
}: BuildingCardProps) {
|
||||||
|
const hasBroken = anchorBroken > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card} onClick={onClick}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.name}>{name}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.workerCount}>
|
||||||
|
<PersonIcon />
|
||||||
|
<span className={styles.count}>{workers.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.anchorStatus}>
|
||||||
|
<span className={styles.anchorLabel}>앵커</span>
|
||||||
|
<div className={styles.anchorValues}>
|
||||||
|
<div className={styles.anchorItem}>
|
||||||
|
<span className={styles.anchorType}>정상</span>
|
||||||
|
<span className={`${styles.anchorValue} ${hasBroken ? styles.dimmed : ''}`}>
|
||||||
|
{anchorNormal}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.anchorItem}>
|
||||||
|
<span className={styles.anchorType}>고장</span>
|
||||||
|
<span className={`${styles.anchorValue} ${hasBroken ? styles.broken : ''}`}>
|
||||||
|
{anchorBroken}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g opacity="0.4">
|
||||||
|
<path d="M8.01136 0.975761C7.50051 0.424261 6.78703 0.120536 5.99956 0.120536C5.20783 0.120536 4.49201 0.422411 3.98356 0.970511C3.46958 1.52464 3.21916 2.27774 3.27796 3.09099C3.39448 4.69539 4.61538 6.00054 5.99956 6.00054C7.38368 6.00054 8.60248 4.69564 8.72088 3.09151C8.78046 2.28564 8.52846 1.53409 8.01136 0.975761Z" fill="white"/>
|
||||||
|
<path d="M10.62 11.8804H1.38005C1.2591 11.882 1.13932 11.8566 1.02945 11.8061C0.91955 11.7555 0.822325 11.6811 0.7448 11.5882C0.574175 11.3843 0.5054 11.1058 0.556325 10.8241C0.777875 9.59508 1.4693 8.56265 2.55607 7.83788C3.52155 7.19453 4.74452 6.8404 6.00005 6.8404C7.25557 6.8404 8.47857 7.19478 9.44405 7.83788C10.5308 8.5624 11.2222 9.5948 11.4438 10.8239C11.4947 11.1055 11.4259 11.384 11.2553 11.588C11.1778 11.6809 11.0806 11.7553 10.9707 11.8059C10.8608 11.8565 10.741 11.882 10.62 11.8804Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/components/facility/CategoryTab.module.css
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
.tabContainer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabContainer::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: #FFFFFF;
|
||||||
|
color: #000000;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.inactive {
|
||||||
|
background: transparent;
|
||||||
|
border-color: rgba(209, 217, 255, 0.3);
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.inactive:hover {
|
||||||
|
border-color: rgba(209, 217, 255, 0.5);
|
||||||
|
}
|
||||||
29
src/components/facility/CategoryTab.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import styles from './CategoryTab.module.css'
|
||||||
|
|
||||||
|
interface CategoryTabProps {
|
||||||
|
categories: string[]
|
||||||
|
activeCategory: string
|
||||||
|
onCategoryChange: (category: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryTab({
|
||||||
|
categories,
|
||||||
|
activeCategory,
|
||||||
|
onCategoryChange
|
||||||
|
}: CategoryTabProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.tabContainer}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
className={`${styles.tab} ${
|
||||||
|
category === activeCategory ? styles.active : styles.inactive
|
||||||
|
}`}
|
||||||
|
onClick={() => onCategoryChange(category)}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
src/components/facility/EntryCard.module.css
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(15, 17, 26, 0.8);
|
||||||
|
backdrop-filter: blur(20.833px);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 0 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainInfo {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locationInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locationName {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(209, 217, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.4px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorInfo {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorLabel {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(209, 217, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorStatus {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot.normal {
|
||||||
|
background: #748CFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot.broken {
|
||||||
|
background: #FF656A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusText {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusText.normal {
|
||||||
|
color: #CECECE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusText.broken {
|
||||||
|
color: #FF656A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 40px;
|
||||||
|
width: 260px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logButton:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logButtonText {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
64
src/components/facility/EntryCard.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import styles from './EntryCard.module.css'
|
||||||
|
|
||||||
|
interface EntryCardProps {
|
||||||
|
floor: string
|
||||||
|
location: string
|
||||||
|
inCount: number
|
||||||
|
outCount: number
|
||||||
|
anchorStatus: 'normal' | 'broken'
|
||||||
|
onLogClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntryCard({
|
||||||
|
floor,
|
||||||
|
location,
|
||||||
|
inCount,
|
||||||
|
outCount,
|
||||||
|
anchorStatus,
|
||||||
|
onLogClick
|
||||||
|
}: EntryCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.mainInfo}>
|
||||||
|
<div className={styles.locationInfo}>
|
||||||
|
<span className={styles.locationName}>{floor}</span>
|
||||||
|
<span className={styles.locationName}>{location}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<div className={styles.stat}>
|
||||||
|
<span className={styles.statLabel}>IN</span>
|
||||||
|
<span className={styles.statValue}>{inCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stat}>
|
||||||
|
<span className={styles.statLabel}>OUT</span>
|
||||||
|
<span className={styles.statValue}>{outCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.anchorInfo}>
|
||||||
|
<span className={styles.anchorLabel}>앵커</span>
|
||||||
|
<div className={styles.anchorStatus}>
|
||||||
|
<div
|
||||||
|
className={`${styles.statusDot} ${
|
||||||
|
anchorStatus === 'normal' ? styles.normal : styles.broken
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`${styles.statusText} ${
|
||||||
|
anchorStatus === 'normal' ? styles.normal : styles.broken
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{anchorStatus === 'normal' ? '정상' : '고장'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={styles.logButton} onClick={onLogClick}>
|
||||||
|
<span className={styles.logButtonText}>출입로그 보기</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
src/components/facility/FacilityCard.module.css
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 128px;
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(108.18deg, rgba(58, 69, 123, 0.64) 1.96%, rgba(36, 44, 85, 0.32) 94.63%);
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
background: linear-gradient(108.18deg, rgba(68, 79, 133, 0.74) 1.96%, rgba(46, 54, 95, 0.42) 94.63%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
background: rgba(21, 27, 52, 0.8);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowIcon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workerInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personIcon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.48px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
47
src/components/facility/FacilityCard.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import styles from './FacilityCard.module.css'
|
||||||
|
|
||||||
|
interface FacilityCardProps {
|
||||||
|
name: string
|
||||||
|
workers: number
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FacilityCard({ name, workers, onClick }: FacilityCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.card} onClick={onClick}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.name}>{name}</span>
|
||||||
|
<div className={styles.arrowButton}>
|
||||||
|
<svg
|
||||||
|
className={styles.arrowIcon}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3.5 8H12.5M12.5 8L8.5 4M12.5 8L8.5 12"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.workerInfo}>
|
||||||
|
<svg
|
||||||
|
className={styles.personIcon}
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 6C7.38071 6 8.5 4.88071 8.5 3.5C8.5 2.11929 7.38071 1 6 1C4.61929 1 3.5 2.11929 3.5 3.5C3.5 4.88071 4.61929 6 6 6ZM6 7.25C4.16625 7.25 0.5 8.17125 0.5 10V11H11.5V10C11.5 8.17125 7.83375 7.25 6 7.25Z"
|
||||||
|
fill="rgba(255, 255, 255, 0.6)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className={styles.count}>{workers.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/components/facility/FloorSection.module.css
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
.section {
|
||||||
|
background: linear-gradient(104deg, #171C30 1.96%, #172241 94.63%);
|
||||||
|
border-radius: 20px;
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
padding: 24px 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 20px;
|
||||||
|
width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floorName {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workerInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personIcon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workerCount {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #D3D3D3;
|
||||||
|
letter-spacing: -0.48px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floorLogButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floorLogButton:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floorLogButtonText {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
width: 320px;
|
||||||
|
margin: 20px auto 0;
|
||||||
|
}
|
||||||
45
src/components/facility/FloorSection.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import styles from './FloorSection.module.css'
|
||||||
|
|
||||||
|
interface FloorSectionProps {
|
||||||
|
floorName: string
|
||||||
|
workerCount: number
|
||||||
|
children: React.ReactNode
|
||||||
|
onFloorLogClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FloorSection({
|
||||||
|
floorName,
|
||||||
|
workerCount,
|
||||||
|
children,
|
||||||
|
onFloorLogClick
|
||||||
|
}: FloorSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.headerWrapper}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.floorName}>{floorName}</span>
|
||||||
|
<div className={styles.workerInfo}>
|
||||||
|
<svg
|
||||||
|
className={styles.personIcon}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8 8C9.84 8 11.333 6.507 11.333 4.667C11.333 2.827 9.84 1.333 8 1.333C6.16 1.333 4.667 2.827 4.667 4.667C4.667 6.507 6.16 8 8 8ZM8 9.667C5.78 9.667 1.333 10.78 1.333 13V14.667H14.667V13C14.667 10.78 10.22 9.667 8 9.667Z"
|
||||||
|
fill="rgba(255, 255, 255, 0.6)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className={styles.workerCount}>{workerCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={styles.floorLogButton} onClick={onFloorLogClick}>
|
||||||
|
<span className={styles.floorLogButtonText}>층별 출입로그 보기</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.entryList}>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/components/layout/Header.module.css
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
top: 40px;
|
||||||
|
left: 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #FFFFFF;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
123
src/components/layout/Header.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import Logo from '../common/Logo'
|
||||||
|
import SideMenu from './SideMenu'
|
||||||
|
import styles from './Header.module.css'
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
showMenu?: boolean
|
||||||
|
showBackButton?: boolean
|
||||||
|
logoPosition?: 'left' | 'center'
|
||||||
|
title?: string
|
||||||
|
onMenuClick?: () => void
|
||||||
|
onBackClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({
|
||||||
|
showMenu = false,
|
||||||
|
showBackButton = false,
|
||||||
|
logoPosition = 'center',
|
||||||
|
title,
|
||||||
|
onMenuClick,
|
||||||
|
onBackClick
|
||||||
|
}: HeaderProps) {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleMenuClick = () => {
|
||||||
|
if (onMenuClick) {
|
||||||
|
onMenuClick()
|
||||||
|
} else {
|
||||||
|
setIsMenuOpen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
if (onBackClick) {
|
||||||
|
onBackClick()
|
||||||
|
} else {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MK-002 layout: Logo left, Menu right
|
||||||
|
if (logoPosition === 'left') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<Logo variant="light" />
|
||||||
|
<div className={styles.spacer} />
|
||||||
|
{showMenu && (
|
||||||
|
<button
|
||||||
|
className={styles.iconButton}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
aria-label="메뉴 열기"
|
||||||
|
>
|
||||||
|
<HamburgerIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<SideMenu isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MK-003 layout: Back button left, Logo center, Menu right
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className={styles.header}>
|
||||||
|
{/* Left: Back button or spacer */}
|
||||||
|
{showBackButton ? (
|
||||||
|
<button
|
||||||
|
className={styles.iconButton}
|
||||||
|
onClick={handleBackClick}
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className={styles.spacer} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Center: Logo or Title */}
|
||||||
|
{title ? (
|
||||||
|
<h1 className={styles.title}>{title}</h1>
|
||||||
|
) : (
|
||||||
|
<Logo variant="light" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right: Menu button or spacer */}
|
||||||
|
{showMenu ? (
|
||||||
|
<button
|
||||||
|
className={styles.iconButton}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
aria-label="메뉴 열기"
|
||||||
|
>
|
||||||
|
<HamburgerIcon />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className={styles.spacer} />
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<SideMenu isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HamburgerIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 5H3" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M21 12H3" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M21 19H3" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 19L8 12L15 5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
src/components/layout/SideMenu.module.css
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 359px;
|
||||||
|
max-width: 100vw;
|
||||||
|
background: #FFFFFF;
|
||||||
|
z-index: 101;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideIn 0.25s ease-out;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 16px 20px 24px;
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 20px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 대시보드 아이템 */
|
||||||
|
.dashboardItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboardItem:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboardText {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 40px;
|
||||||
|
color: #000000;
|
||||||
|
letter-spacing: -0.48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 구분선 */
|
||||||
|
.divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background: #E5E5E5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionDivider {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #EFEFEF;
|
||||||
|
margin: 0 -20px;
|
||||||
|
width: calc(100% + 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 섹션 */
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 40px;
|
||||||
|
color: #999999;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 메뉴 아이템 */
|
||||||
|
.menuItems {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItemsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
gap: 16px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #000000;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
110
src/components/layout/SideMenu.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import Logo from '../common/Logo'
|
||||||
|
import styles from './SideMenu.module.css'
|
||||||
|
|
||||||
|
interface SideMenuProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleNavigate = (path: string) => {
|
||||||
|
navigate(path)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.overlay} onClick={onClose} />
|
||||||
|
<aside className={styles.menu}>
|
||||||
|
{/* Header */}
|
||||||
|
<header className={styles.header}>
|
||||||
|
<Logo variant="dark" />
|
||||||
|
<button className={styles.closeButton} onClick={onClose} aria-label="메뉴 닫기">
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className={styles.nav}>
|
||||||
|
{/* 대시보드 */}
|
||||||
|
<div className={styles.dashboardItem} onClick={() => handleNavigate('/dashboard')}>
|
||||||
|
<span className={styles.dashboardText}>대시보드</span>
|
||||||
|
<ChevronIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.divider} />
|
||||||
|
|
||||||
|
{/* 출입기록 검색 */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>출입기록 검색</h3>
|
||||||
|
<div className={styles.menuItems}>
|
||||||
|
<button className={styles.menuItem} onClick={() => handleNavigate('/facility-status')}>
|
||||||
|
시설별 현황
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비상대응 관리 */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>비상대응 관리</h3>
|
||||||
|
<div className={styles.menuItemsGrid}>
|
||||||
|
<button className={styles.menuItem} onClick={() => handleNavigate('/emergency-alert')}>
|
||||||
|
비상경보 발생
|
||||||
|
</button>
|
||||||
|
<button className={styles.menuItem} onClick={() => handleNavigate('/emergency-fire')}>
|
||||||
|
대피소 현황
|
||||||
|
</button>
|
||||||
|
<button className={styles.menuItem} onClick={() => handleNavigate('/qr-scan')}>
|
||||||
|
인원태깅
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 태그설정 */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>태그설정</h3>
|
||||||
|
<div className={styles.menuItems}>
|
||||||
|
<button className={styles.menuItem} onClick={() => handleNavigate('/tag-battery')}>
|
||||||
|
태그 배터리
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.sectionDivider} />
|
||||||
|
|
||||||
|
{/* 관리자설정 */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>관리자설정</h3>
|
||||||
|
<div className={styles.menuItems}>
|
||||||
|
<button className={styles.menuItem} onClick={() => handleNavigate('/my-info')}>
|
||||||
|
내 정보 설정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M28 12L12 28" stroke="black" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M12 12L28 28" stroke="black" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="10" height="14" viewBox="0 0 10 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 1L7 7L1 13" stroke="black" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './styles/global.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
104
src/pages/AccessLogPage.module.css
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #FFFFFF;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
padding-top: 50px;
|
||||||
|
width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 32px;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
color: #000000;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logTable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
height: 20px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111111;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colAccess {
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colTagId {
|
||||||
|
width: 85px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colTime {
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
letter-spacing: -0.28px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader .colTime {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
75
src/pages/AccessLogPage.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import styles from './AccessLogPage.module.css'
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: number
|
||||||
|
type: 'IN' | 'OUT'
|
||||||
|
tagId: string
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data - 추후 API 연동
|
||||||
|
const mockLogs: LogEntry[] = [
|
||||||
|
{ id: 1, type: 'IN', tagId: 'MK0100', time: '2025-12-01 14:36:12' },
|
||||||
|
{ id: 2, type: 'IN', tagId: 'MK0100', time: '2025-12-01 14:36:12' },
|
||||||
|
{ id: 3, type: 'IN', tagId: 'MK0100', time: '2025-12-01 14:36:12' },
|
||||||
|
{ id: 4, type: 'OUT', tagId: 'MK0101', time: '2025-12-01 15:00:00' },
|
||||||
|
{ id: 5, type: 'OUT', tagId: 'MK0101', time: '2025-12-01 15:00:00' },
|
||||||
|
{ id: 6, type: 'OUT', tagId: 'MK0101', time: '2025-12-01 15:00:00' },
|
||||||
|
{ id: 7, type: 'IN', tagId: 'MK0102', time: '2025-12-01 15:30:45' },
|
||||||
|
{ id: 8, type: 'IN', tagId: 'MK0102', time: '2025-12-01 15:30:45' },
|
||||||
|
{ id: 9, type: 'IN', tagId: 'MK0102', time: '2025-12-01 15:30:45' },
|
||||||
|
{ id: 10, type: 'OUT', tagId: 'MK0103', time: '2025-12-01 16:15:20' },
|
||||||
|
{ id: 11, type: 'OUT', tagId: 'MK0103', time: '2025-12-01 16:15:20' },
|
||||||
|
{ id: 12, type: 'OUT', tagId: 'MK0103', time: '2025-12-01 16:15:20' },
|
||||||
|
{ id: 13, type: 'IN', tagId: 'MK0104', time: '2025-12-01 16:45:10' },
|
||||||
|
{ id: 14, type: 'IN', tagId: 'MK0104', time: '2025-12-01 16:45:10' },
|
||||||
|
{ id: 15, type: 'OUT', tagId: 'MK0105', time: '2025-12-01 17:00:55' },
|
||||||
|
{ id: 16, type: 'IN', tagId: 'MK0106', time: '2025-12-01 17:30:30' },
|
||||||
|
{ id: 17, type: 'IN', tagId: 'MK0106', time: '2025-12-01 17:30:30' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AccessLogPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { locationName } = useParams<{ locationName: string }>()
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Close Button */}
|
||||||
|
<button className={styles.closeButton} onClick={handleClose} aria-label="닫기">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h1 className={styles.title}>{locationName || '옥상출입구'} 출입로그</h1>
|
||||||
|
|
||||||
|
<div className={styles.logTable}>
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<span className={styles.colAccess}>출입</span>
|
||||||
|
<span className={styles.colTagId}>TagID</span>
|
||||||
|
<span className={styles.colTime}>시간</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Body */}
|
||||||
|
<div className={styles.tableBody}>
|
||||||
|
{mockLogs.map((log) => (
|
||||||
|
<div key={log.id} className={styles.tableRow}>
|
||||||
|
<span className={styles.colAccess}>{log.type}</span>
|
||||||
|
<span className={styles.colTagId}>{log.tagId}</span>
|
||||||
|
<span className={styles.colTime}>{log.time}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
src/pages/AllLogPage.module.css
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #FFFFFF;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
padding-top: 50px;
|
||||||
|
width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 32px;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
color: #000000;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logTable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
height: 20px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colDoor {
|
||||||
|
width: 80px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colAccess {
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colTagId {
|
||||||
|
width: 85px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colTime {
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
letter-spacing: -0.28px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader .colTime {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
77
src/pages/AllLogPage.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import styles from './AllLogPage.module.css'
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: number
|
||||||
|
door: string
|
||||||
|
type: 'IN' | 'OUT'
|
||||||
|
tagId: string
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data - 추후 API 연동
|
||||||
|
const mockLogs: LogEntry[] = [
|
||||||
|
{ id: 1, door: '옥상출입구', type: 'IN', tagId: 'MK0100', time: '2025-12-01 14:36:12' },
|
||||||
|
{ id: 2, door: '옥상출입구', type: 'IN', tagId: 'MK0100', time: '2025-12-01 14:36:12' },
|
||||||
|
{ id: 3, door: '옥상출입구', type: 'IN', tagId: 'MK0100', time: '2025-12-01 14:36:12' },
|
||||||
|
{ id: 4, door: '서쪽출입문', type: 'OUT', tagId: 'MK0101', time: '2025-12-01 15:00:00' },
|
||||||
|
{ id: 5, door: '서쪽출입문', type: 'OUT', tagId: 'MK0101', time: '2025-12-01 15:00:00' },
|
||||||
|
{ id: 6, door: '서쪽출입문', type: 'OUT', tagId: 'MK0101', time: '2025-12-01 15:00:00' },
|
||||||
|
{ id: 7, door: '동쪽출입문', type: 'IN', tagId: 'MK0102', time: '2025-12-01 15:30:45' },
|
||||||
|
{ id: 8, door: '동쪽출입문', type: 'IN', tagId: 'MK0102', time: '2025-12-01 15:30:45' },
|
||||||
|
{ id: 9, door: '동쪽출입문', type: 'IN', tagId: 'MK0102', time: '2025-12-01 15:30:45' },
|
||||||
|
{ id: 10, door: '남쪽출입문', type: 'OUT', tagId: 'MK0103', time: '2025-12-01 16:15:20' },
|
||||||
|
{ id: 11, door: '남쪽출입문', type: 'OUT', tagId: 'MK0103', time: '2025-12-01 16:15:20' },
|
||||||
|
{ id: 12, door: '남쪽출입문', type: 'OUT', tagId: 'MK0103', time: '2025-12-01 16:15:20' },
|
||||||
|
{ id: 13, door: '옥상출입구', type: 'IN', tagId: 'MK0104', time: '2025-12-01 16:45:10' },
|
||||||
|
{ id: 14, door: '옥상출입구', type: 'IN', tagId: 'MK0104', time: '2025-12-01 16:45:10' },
|
||||||
|
{ id: 15, door: '서쪽출입문', type: 'OUT', tagId: 'MK0105', time: '2025-12-01 17:00:55' },
|
||||||
|
{ id: 16, door: '동쪽출입문', type: 'IN', tagId: 'MK0106', time: '2025-12-01 17:30:30' },
|
||||||
|
{ id: 17, door: '동쪽출입문', type: 'IN', tagId: 'MK0106', time: '2025-12-01 17:30:30' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AllLogPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Close Button */}
|
||||||
|
<button className={styles.closeButton} onClick={handleClose} aria-label="닫기">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h1 className={styles.title}>전체 출입로그</h1>
|
||||||
|
|
||||||
|
<div className={styles.logTable}>
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<span className={styles.colDoor}>출입문</span>
|
||||||
|
<span className={styles.colAccess}>출입</span>
|
||||||
|
<span className={styles.colTagId}>TagID</span>
|
||||||
|
<span className={styles.colTime}>시간</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Body */}
|
||||||
|
<div className={styles.tableBody}>
|
||||||
|
{mockLogs.map((log) => (
|
||||||
|
<div key={log.id} className={styles.tableRow}>
|
||||||
|
<span className={styles.colDoor}>{log.door}</span>
|
||||||
|
<span className={styles.colAccess}>{log.type}</span>
|
||||||
|
<span className={styles.colTagId}>{log.tagId}</span>
|
||||||
|
<span className={styles.colTime}>{log.time}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
src/pages/DashboardPage.module.css
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(157deg, #010306 0.81%, #091A3F 100%);
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background blur effect */
|
||||||
|
.bgBlur {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
right: -234px;
|
||||||
|
top: -275px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 82.5%);
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 전체 현황 섹션 */
|
||||||
|
.statusSection {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 120px 20px 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusMetrics {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricLabel {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #748CFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricValue {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
text-shadow: 0px 0px 6.557px rgba(110, 120, 191, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 건물 선택 섹션 */
|
||||||
|
.buildingSection {
|
||||||
|
position: relative;
|
||||||
|
margin: 20px 10px 0;
|
||||||
|
padding: 24px 10px;
|
||||||
|
background: linear-gradient(100.52deg, #171C30 1.96%, #172241 94.63%);
|
||||||
|
border-radius: 20px;
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buildingSectionHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px 0 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buildingSectionTitle {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buildingGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 배터리 헬스 체크 섹션 */
|
||||||
|
.batterySection {
|
||||||
|
position: relative;
|
||||||
|
margin: 20px 10px 0;
|
||||||
|
padding: 24px 10px;
|
||||||
|
background: linear-gradient(120.27deg, #222843 1.96%, #172241 94.63%);
|
||||||
|
border-radius: 20px;
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batterySectionHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px 0 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batterySectionTitle {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batteryGrid {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
84
src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import styles from './DashboardPage.module.css'
|
||||||
|
import Header from '../components/layout/Header'
|
||||||
|
import BuildingCard from '../components/dashboard/BuildingCard'
|
||||||
|
import BatteryHealthCard from '../components/dashboard/BatteryHealthCard'
|
||||||
|
|
||||||
|
// 건물 데이터 (추후 API 연동)
|
||||||
|
const buildings = [
|
||||||
|
{ id: 1, name: '물류동', workers: 1107, anchorNormal: 6, anchorBroken: 0 },
|
||||||
|
{ id: 2, name: '생산동', workers: 1107, anchorNormal: 12, anchorBroken: 3 },
|
||||||
|
{ id: 3, name: '관리동', workers: 1107, anchorNormal: 2, anchorBroken: 3 },
|
||||||
|
{ id: 4, name: '위험물', workers: 117, anchorNormal: 12, anchorBroken: 0 },
|
||||||
|
{ id: 5, name: '휴게동', workers: 1107, anchorNormal: 120, anchorBroken: 1 },
|
||||||
|
{ id: 6, name: '식당', workers: 25, anchorNormal: 6, anchorBroken: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Background blur effect */}
|
||||||
|
<div className={styles.bgBlur} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Header showMenu logoPosition="left" />
|
||||||
|
|
||||||
|
{/* 전체 현황 섹션 */}
|
||||||
|
<section className={styles.statusSection}>
|
||||||
|
<h2 className={styles.sectionTitle}>전체 현황</h2>
|
||||||
|
<div className={styles.statusMetrics}>
|
||||||
|
<div className={styles.metric}>
|
||||||
|
<span className={styles.metricLabel}>등록된 작업자</span>
|
||||||
|
<span className={styles.metricValue}>4,597</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.metric}>
|
||||||
|
<span className={styles.metricLabel}>단지내 재실자</span>
|
||||||
|
<span className={styles.metricValue}>4,487</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.metric}>
|
||||||
|
<span className={styles.metricLabel}>미입실</span>
|
||||||
|
<span className={styles.metricValue}>110</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 건물 선택 섹션 */}
|
||||||
|
<section className={styles.buildingSection}>
|
||||||
|
<div className={styles.buildingSectionHeader}>
|
||||||
|
<span className={styles.buildingSectionTitle}>건물을 선택해주세요.</span>
|
||||||
|
<ChevronIcon />
|
||||||
|
</div>
|
||||||
|
<div className={styles.buildingGrid}>
|
||||||
|
{buildings.map((building) => (
|
||||||
|
<BuildingCard
|
||||||
|
key={building.id}
|
||||||
|
name={building.name}
|
||||||
|
workers={building.workers}
|
||||||
|
anchorNormal={building.anchorNormal}
|
||||||
|
anchorBroken={building.anchorBroken}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 배터리 헬스 체크 섹션 */}
|
||||||
|
<section className={styles.batterySection}>
|
||||||
|
<div className={styles.batterySectionHeader}>
|
||||||
|
<span className={styles.batterySectionTitle}>배터리 헬스 체크</span>
|
||||||
|
<ChevronIcon />
|
||||||
|
</div>
|
||||||
|
<div className={styles.batteryGrid}>
|
||||||
|
<BatteryHealthCard level="high" value={4490} />
|
||||||
|
<BatteryHealthCard level="low" value={104} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: 'rotate(-90deg)' }}>
|
||||||
|
<path d="M20.1481 11L14.5 17L8.85189 11" stroke="white" strokeWidth="1.5"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
209
src/pages/EmergencyAlertPage.module.css
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(146deg, #010306 0.81%, #091A3F 100%);
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background blur effect - Figma Ellipse style */
|
||||||
|
.bgBlur {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
left: 198px;
|
||||||
|
top: -275px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 63.68%);
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert Popup */
|
||||||
|
.popup {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: calc(50% + 22px);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 340px;
|
||||||
|
height: 490px;
|
||||||
|
background: linear-gradient(193deg, rgb(195, 0, 0) 4.28%, rgb(123, 14, 11) 93.12%);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupBgImage {
|
||||||
|
position: absolute;
|
||||||
|
left: 160px;
|
||||||
|
top: 80px;
|
||||||
|
width: 303px;
|
||||||
|
height: 303px;
|
||||||
|
object-fit: cover;
|
||||||
|
filter: blur(2px);
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: rotate(20.5deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupContent {
|
||||||
|
position: absolute;
|
||||||
|
top: 24px;
|
||||||
|
left: 10px;
|
||||||
|
width: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 36px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alertType {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shelter info card */
|
||||||
|
.shelterCard {
|
||||||
|
position: absolute;
|
||||||
|
top: 184px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 300px;
|
||||||
|
height: 180px;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelterTag {
|
||||||
|
position: absolute;
|
||||||
|
top: 19px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: #6d0100;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.32px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelterLocation {
|
||||||
|
position: absolute;
|
||||||
|
top: 85px;
|
||||||
|
left: calc(50% + 100px);
|
||||||
|
transform: translate(-100%, -50%);
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 32px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
text-align: right;
|
||||||
|
letter-spacing: -0.64px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evacuationStats {
|
||||||
|
position: absolute;
|
||||||
|
top: 131px;
|
||||||
|
left: 82px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evacuatedCount {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsDivider {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #ff7c7c;
|
||||||
|
letter-spacing: -0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalCount {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #ff7c7c;
|
||||||
|
letter-spacing: -0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm button */
|
||||||
|
.confirmButton {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
width: 300px;
|
||||||
|
height: 60px;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmButton:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
70
src/pages/EmergencyAlertPage.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import Header from '../components/layout/Header'
|
||||||
|
import styles from './EmergencyAlertPage.module.css'
|
||||||
|
import emergencyBellImg from '../assets/emergency-bell.png'
|
||||||
|
|
||||||
|
// Mock data - 추후 API 연동
|
||||||
|
const alertData = {
|
||||||
|
timestamp: '2025-11-18 13:01:24',
|
||||||
|
type: '비상 : 화재',
|
||||||
|
title: '비상알림',
|
||||||
|
description: '생산동 2층 유류화재 발생',
|
||||||
|
shelter: '물류동 4층 옥상',
|
||||||
|
evacuated: 2345,
|
||||||
|
total: 4597
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmergencyAlertPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Background blur effect */}
|
||||||
|
<div className={styles.bgBlur} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Header showBackButton showMenu />
|
||||||
|
|
||||||
|
{/* Alert Popup */}
|
||||||
|
<div className={styles.popup}>
|
||||||
|
{/* Background image */}
|
||||||
|
<img src={emergencyBellImg} alt="" className={styles.popupBgImage} />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={styles.popupContent}>
|
||||||
|
{/* Top row: timestamp and type */}
|
||||||
|
<div className={styles.topRow}>
|
||||||
|
<span className={styles.timestamp}>{alertData.timestamp}</span>
|
||||||
|
<span className={styles.alertType}>{alertData.type}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main alert info */}
|
||||||
|
<div className={styles.mainInfo}>
|
||||||
|
<h1 className={styles.title}>{alertData.title}</h1>
|
||||||
|
<p className={styles.description}>{alertData.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shelter info card */}
|
||||||
|
<div className={styles.shelterCard}>
|
||||||
|
<div className={styles.shelterTag}>담당 대피소</div>
|
||||||
|
<div className={styles.shelterLocation}>{alertData.shelter}</div>
|
||||||
|
<div className={styles.evacuationStats}>
|
||||||
|
<span className={styles.evacuatedCount}>{alertData.evacuated.toLocaleString()}</span>
|
||||||
|
<span className={styles.statsDivider}>/</span>
|
||||||
|
<span className={styles.totalCount}>{alertData.total.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm button */}
|
||||||
|
<button className={styles.confirmButton} onClick={handleConfirm}>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
244
src/pages/EmergencyFirePage.module.css
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(158deg, #010306 0.81%, #000000 100%);
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background red ellipse blur effect */
|
||||||
|
.bgRedEllipse {
|
||||||
|
position: absolute;
|
||||||
|
width: 415px;
|
||||||
|
height: 397px;
|
||||||
|
left: 107px;
|
||||||
|
top: -34px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgRedEllipse img {
|
||||||
|
position: absolute;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background emergency bell */
|
||||||
|
.bgBellWrapper {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 348px;
|
||||||
|
height: 348px;
|
||||||
|
left: 151px;
|
||||||
|
top: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgBellImage {
|
||||||
|
width: 270px;
|
||||||
|
height: 270px;
|
||||||
|
object-fit: cover;
|
||||||
|
transform: rotate(20.5deg);
|
||||||
|
filter: blur(2px);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 담당 대피소 카드 */
|
||||||
|
.shelterAssignCard {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 120px 20px 0;
|
||||||
|
padding: 24px 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelterLabel {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelterName {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taggingButton {
|
||||||
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taggingButton:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 통계 정보 */
|
||||||
|
.statsContainer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 24px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #FFFCFC;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 카드 목록 */
|
||||||
|
.cardList {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 24px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 대피소 카드 */
|
||||||
|
.shelterCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelterCard:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelterCardHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelterCardName {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelterCardCount {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelterCardValue {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 미대피자 목록 카드 */
|
||||||
|
.notEvacuatedCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.4) 50%, rgba(232, 4, 0, 0.4) 114.14%);
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notEvacuatedCard:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.5) 50%, rgba(232, 4, 0, 0.5) 114.14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notEvacuatedHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notEvacuatedTitle {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notEvacuatedGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notEvacuatedItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notEvacuatedBuilding {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #777777;
|
||||||
|
letter-spacing: -0.28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notEvacuatedCount {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.48px;
|
||||||
|
}
|
||||||
149
src/pages/EmergencyFirePage.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import Header from '../components/layout/Header'
|
||||||
|
import styles from './EmergencyFirePage.module.css'
|
||||||
|
import emergencyBellImg from '../assets/emergency-bell.png'
|
||||||
|
import redEllipseBg from '../assets/red-ellipse-bg.svg'
|
||||||
|
import arrowIcon from '../assets/arrow-up-right.svg'
|
||||||
|
|
||||||
|
// Mock data - 추후 API 연동
|
||||||
|
const emergencyData = {
|
||||||
|
type: '비상 : 화재',
|
||||||
|
shelter: {
|
||||||
|
name: '물류동 4층 옥상'
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
total: 4597,
|
||||||
|
remaining: 2087,
|
||||||
|
evacuated: 2345
|
||||||
|
},
|
||||||
|
shelters: [
|
||||||
|
{ id: 1, name: '휴게동 테니스장', count: 1569 },
|
||||||
|
{ id: 2, name: '구내식당', count: 352 }
|
||||||
|
],
|
||||||
|
notEvacuated: [
|
||||||
|
{ building: 'A동', count: 1435 },
|
||||||
|
{ building: 'B동', count: 356 },
|
||||||
|
{ building: 'C동', count: 7 },
|
||||||
|
{ building: 'D동', count: 156 },
|
||||||
|
{ building: 'E동', count: 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmergencyFirePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleTagging = () => {
|
||||||
|
// 인원태깅 기능 구현
|
||||||
|
console.log('인원태깅 클릭')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShelterClick = () => {
|
||||||
|
// 대피자 목록 페이지로 이동
|
||||||
|
navigate('/emergency-fire/evacuee-list')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotEvacuatedClick = () => {
|
||||||
|
// 미대피자 목록 페이지로 이동
|
||||||
|
navigate('/emergency-fire/non-evacuee-list')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Background red ellipse blur effect */}
|
||||||
|
<div className={styles.bgRedEllipse}>
|
||||||
|
<img src={redEllipseBg} alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Background emergency bell image */}
|
||||||
|
<div className={styles.bgBellWrapper}>
|
||||||
|
<img src={emergencyBellImg} alt="" className={styles.bgBellImage} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Header showBackButton showMenu title={emergencyData.type} />
|
||||||
|
|
||||||
|
{/* 담당 대피소 카드 */}
|
||||||
|
<div className={styles.shelterAssignCard}>
|
||||||
|
<span className={styles.shelterLabel}>담당 대피소</span>
|
||||||
|
<span className={styles.shelterName}>{emergencyData.shelter.name}</span>
|
||||||
|
<button className={styles.taggingButton} onClick={handleTagging}>
|
||||||
|
인원태깅
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 정보 */}
|
||||||
|
<div className={styles.statsContainer}>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statLabel}>총원</span>
|
||||||
|
<span className={styles.statValue}>{emergencyData.stats.total.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statLabel}>건물내 잔류</span>
|
||||||
|
<span className={styles.statValue}>{emergencyData.stats.remaining.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statLabel}>퇴실(대피소)</span>
|
||||||
|
<span className={styles.statValue}>{emergencyData.stats.evacuated.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대피소 카드 목록 */}
|
||||||
|
<div className={styles.cardList}>
|
||||||
|
{emergencyData.shelters.map((shelter) => (
|
||||||
|
<button
|
||||||
|
key={shelter.id}
|
||||||
|
className={styles.shelterCard}
|
||||||
|
onClick={() => handleShelterClick()}
|
||||||
|
>
|
||||||
|
<div className={styles.shelterCardHeader}>
|
||||||
|
<span className={styles.shelterCardName}>{shelter.name}</span>
|
||||||
|
<ArrowIcon />
|
||||||
|
</div>
|
||||||
|
<div className={styles.shelterCardCount}>
|
||||||
|
<PersonIcon />
|
||||||
|
<span className={styles.shelterCardValue}>{shelter.count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 미대피자 목록 카드 */}
|
||||||
|
<button className={styles.notEvacuatedCard} onClick={handleNotEvacuatedClick}>
|
||||||
|
<div className={styles.notEvacuatedHeader}>
|
||||||
|
<span className={styles.notEvacuatedTitle}>미대피자 목록</span>
|
||||||
|
<ArrowIcon />
|
||||||
|
</div>
|
||||||
|
<div className={styles.notEvacuatedGrid}>
|
||||||
|
{emergencyData.notEvacuated.map((item, index) => (
|
||||||
|
<div key={index} className={styles.notEvacuatedItem}>
|
||||||
|
<span className={styles.notEvacuatedBuilding}>{item.building}</span>
|
||||||
|
<span className={styles.notEvacuatedCount}>{item.count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 6C7.38071 6 8.5 4.88071 8.5 3.5C8.5 2.11929 7.38071 1 6 1C4.61929 1 3.5 2.11929 3.5 3.5C3.5 4.88071 4.61929 6 6 6Z" fill="white"/>
|
||||||
|
<path d="M6 7C3.23858 7 1 9.23858 1 12H11C11 9.23858 8.76142 7 6 7Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrowIcon() {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={arrowIcon}
|
||||||
|
alt=""
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
style={{ transform: 'scaleY(-1)' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
145
src/pages/EvacueeListPage.module.css
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #FFFFFF;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 닫기 버튼 */
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제목 섹션 */
|
||||||
|
.titleSection {
|
||||||
|
padding: 50px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 32px;
|
||||||
|
color: #000000;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 로그 컨텐츠 */
|
||||||
|
.logContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 28px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 카테고리 탭 */
|
||||||
|
.categoryTabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
margin-left: -24px;
|
||||||
|
margin-right: -24px;
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryTabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryTab {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 100px;
|
||||||
|
border: 1px solid #DDDDDD;
|
||||||
|
background: transparent;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #000000;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryTab.active {
|
||||||
|
background: #000000;
|
||||||
|
border-color: #000000;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 대피소 정보 */
|
||||||
|
.shelterInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelterTitle {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 검색결과 테이블 */
|
||||||
|
.resultTable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCell {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #000000;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableCell {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
105
src/pages/EvacueeListPage.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import styles from './EvacueeListPage.module.css'
|
||||||
|
import closeIcon from '../assets/close-icon.svg'
|
||||||
|
|
||||||
|
// Mock data - 추후 API 연동
|
||||||
|
const categories = ['구내식당', '휴게동', '주차장', '옥상']
|
||||||
|
|
||||||
|
const evacueeData: Record<string, { count: number; list: { tagId: string; datetime: string }[] }> = {
|
||||||
|
'구내식당': {
|
||||||
|
count: 352,
|
||||||
|
list: Array(16).fill(null).map((_, i) => ({
|
||||||
|
tagId: `MK${String(100 + i).padStart(4, '0')}`,
|
||||||
|
datetime: '2025-12-23 15:00:23'
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
'휴게동': {
|
||||||
|
count: 128,
|
||||||
|
list: Array(10).fill(null).map((_, i) => ({
|
||||||
|
tagId: `MK${String(200 + i).padStart(4, '0')}`,
|
||||||
|
datetime: '2025-12-23 14:30:15'
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
'주차장': {
|
||||||
|
count: 45,
|
||||||
|
list: Array(5).fill(null).map((_, i) => ({
|
||||||
|
tagId: `MK${String(300 + i).padStart(4, '0')}`,
|
||||||
|
datetime: '2025-12-23 14:15:00'
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
'옥상': {
|
||||||
|
count: 67,
|
||||||
|
list: Array(8).fill(null).map((_, i) => ({
|
||||||
|
tagId: `MK${String(400 + i).padStart(4, '0')}`,
|
||||||
|
datetime: '2025-12-23 13:45:30'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EvacueeListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState(categories[0])
|
||||||
|
|
||||||
|
const currentData = evacueeData[selectedCategory]
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 닫기 버튼 */}
|
||||||
|
<button className={styles.closeButton} onClick={handleClose}>
|
||||||
|
<img src={closeIcon} alt="닫기" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className={styles.titleSection}>
|
||||||
|
<h1 className={styles.title}>대피자 목록</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그 컨텐츠 */}
|
||||||
|
<div className={styles.logContent}>
|
||||||
|
{/* 카테고리 탭 */}
|
||||||
|
<div className={styles.categoryTabs}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
className={`${styles.categoryTab} ${selectedCategory === category ? styles.active : ''}`}
|
||||||
|
onClick={() => setSelectedCategory(category)}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대피소 정보 */}
|
||||||
|
<div className={styles.shelterInfo}>
|
||||||
|
<span className={styles.shelterTitle}>
|
||||||
|
대피소: {selectedCategory} ({currentData.count})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색결과 테이블 */}
|
||||||
|
<div className={styles.resultTable}>
|
||||||
|
{/* 테이블 헤더 */}
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<span className={styles.headerCell}>TagID</span>
|
||||||
|
<span className={styles.headerCell}>확인 일시</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 바디 */}
|
||||||
|
<div className={styles.tableBody}>
|
||||||
|
{currentData.list.map((item, index) => (
|
||||||
|
<div key={index} className={styles.tableRow}>
|
||||||
|
<span className={styles.tableCell}>{item.tagId}</span>
|
||||||
|
<span className={styles.tableCell}>{item.datetime}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/pages/FacilityDetailPage.module.css
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(146deg, #010306 0.81%, #091A3F 100%);
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background blur effect */
|
||||||
|
.bgBlur {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
right: -234px;
|
||||||
|
top: -275px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 82.5%);
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 92px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category tabs */
|
||||||
|
.categoryArea {
|
||||||
|
padding-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All log link */
|
||||||
|
.allLogLink {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 340px;
|
||||||
|
margin: 10px auto;
|
||||||
|
padding: 0 10px 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allLogLink:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allLogText {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronIcon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floor sections */
|
||||||
|
.floorSections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
140
src/pages/FacilityDetailPage.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import Header from '../components/layout/Header'
|
||||||
|
import CategoryTab from '../components/facility/CategoryTab'
|
||||||
|
import FloorSection from '../components/facility/FloorSection'
|
||||||
|
import EntryCard from '../components/facility/EntryCard'
|
||||||
|
import styles from './FacilityDetailPage.module.css'
|
||||||
|
|
||||||
|
const categories = ['물류동', '생산동', '관리동', '위험물', '휴게동', '식당']
|
||||||
|
|
||||||
|
// Mock data - 추후 API 연동
|
||||||
|
const floorData = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '2F(RF)',
|
||||||
|
workerCount: 7,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
floor: '2F(RF)',
|
||||||
|
location: '옥상 출입구',
|
||||||
|
inCount: 56,
|
||||||
|
outCount: 25,
|
||||||
|
anchorStatus: 'normal' as const
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '2F',
|
||||||
|
workerCount: 86,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
floor: '2F',
|
||||||
|
location: '서쪽출입문',
|
||||||
|
inCount: 56,
|
||||||
|
outCount: 25,
|
||||||
|
anchorStatus: 'broken' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
floor: '2F',
|
||||||
|
location: '동쪽출입문',
|
||||||
|
inCount: 56,
|
||||||
|
outCount: 25,
|
||||||
|
anchorStatus: 'normal' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
floor: '2F',
|
||||||
|
location: '남쪽출입문',
|
||||||
|
inCount: 56,
|
||||||
|
outCount: 25,
|
||||||
|
anchorStatus: 'normal' as const
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function FacilityDetailPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { facilityName } = useParams<{ facilityName: string }>()
|
||||||
|
const [activeCategory, setActiveCategory] = useState(facilityName || '물류동')
|
||||||
|
|
||||||
|
const handleAllLogClick = () => {
|
||||||
|
navigate(`/facility-status/${activeCategory}/all-log`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFloorLogClick = (floorName: string) => {
|
||||||
|
navigate(`/facility-status/${activeCategory}/floor-log/${encodeURIComponent(floorName)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEntryLogClick = (location: string) => {
|
||||||
|
navigate(`/facility-status/${activeCategory}/log/${encodeURIComponent(location)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Background blur effect */}
|
||||||
|
<div className={styles.bgBlur} />
|
||||||
|
|
||||||
|
{/* Header with back button and menu */}
|
||||||
|
<Header showBackButton showMenu />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={styles.content}>
|
||||||
|
{/* Category Tabs */}
|
||||||
|
<div className={styles.categoryArea}>
|
||||||
|
<CategoryTab
|
||||||
|
categories={categories}
|
||||||
|
activeCategory={activeCategory}
|
||||||
|
onCategoryChange={setActiveCategory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All Log Link */}
|
||||||
|
<div className={styles.allLogLink} onClick={handleAllLogClick}>
|
||||||
|
<span className={styles.allLogText}>전체 출입로그 보기</span>
|
||||||
|
<svg
|
||||||
|
className={styles.chevronIcon}
|
||||||
|
viewBox="0 0 28 28"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10.5 7L17.5 14L10.5 21"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floor Sections */}
|
||||||
|
<div className={styles.floorSections}>
|
||||||
|
{floorData.map((floor) => (
|
||||||
|
<FloorSection
|
||||||
|
key={floor.id}
|
||||||
|
floorName={floor.name}
|
||||||
|
workerCount={floor.workerCount}
|
||||||
|
onFloorLogClick={() => handleFloorLogClick(floor.name)}
|
||||||
|
>
|
||||||
|
{floor.entries.map((entry) => (
|
||||||
|
<EntryCard
|
||||||
|
key={entry.id}
|
||||||
|
floor={entry.floor}
|
||||||
|
location={entry.location}
|
||||||
|
inCount={entry.inCount}
|
||||||
|
outCount={entry.outCount}
|
||||||
|
anchorStatus={entry.anchorStatus}
|
||||||
|
onLogClick={() => handleEntryLogClick(entry.location)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</FloorSection>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src/pages/FacilityStatusPage.module.css
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(163deg, #010306 0.81%, #091A3F 100%);
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background blur effect */
|
||||||
|
.bgBlur {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
right: -234px;
|
||||||
|
top: -275px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 82.5%);
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
padding: 120px 20px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page title */
|
||||||
|
.pageTitle {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
margin: 0 0 36px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section title */
|
||||||
|
.sectionTitle {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Facility grid */
|
||||||
|
.facilityGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
55
src/pages/FacilityStatusPage.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import Header from '../components/layout/Header'
|
||||||
|
import FacilityCard from '../components/facility/FacilityCard'
|
||||||
|
import styles from './FacilityStatusPage.module.css'
|
||||||
|
|
||||||
|
interface Facility {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
workers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FacilityStatusPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const facilities: Facility[] = [
|
||||||
|
{ id: 1, name: '물류동', workers: 1256 },
|
||||||
|
{ id: 2, name: '생산동', workers: 564 },
|
||||||
|
{ id: 3, name: '관리동', workers: 2 },
|
||||||
|
{ id: 4, name: '위험물', workers: 36 },
|
||||||
|
{ id: 5, name: '휴게동', workers: 56 },
|
||||||
|
{ id: 6, name: '생산동', workers: 64 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleFacilityClick = (facility: Facility) => {
|
||||||
|
navigate(`/facility-status/${facility.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Background blur effect */}
|
||||||
|
<div className={styles.bgBlur} />
|
||||||
|
|
||||||
|
{/* Header with back button and menu */}
|
||||||
|
<Header showBackButton showMenu />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h1 className={styles.pageTitle}>시설별 현황</h1>
|
||||||
|
|
||||||
|
<h2 className={styles.sectionTitle}>전체 건물</h2>
|
||||||
|
|
||||||
|
<div className={styles.facilityGrid}>
|
||||||
|
{facilities.map((facility) => (
|
||||||
|
<FacilityCard
|
||||||
|
key={facility.id}
|
||||||
|
name={facility.name}
|
||||||
|
workers={facility.workers}
|
||||||
|
onClick={() => handleFacilityClick(facility)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
src/pages/FloorLogPage.module.css
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #FFFFFF;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
padding-top: 50px;
|
||||||
|
width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 32px;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
color: #000000;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logTable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
height: 20px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colDoor {
|
||||||
|
width: 80px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colAccess {
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colTagId {
|
||||||
|
width: 85px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colTime {
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
letter-spacing: -0.28px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader .colTime {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
78
src/pages/FloorLogPage.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import styles from './FloorLogPage.module.css'
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: number
|
||||||
|
door: string
|
||||||
|
type: 'IN' | 'OUT'
|
||||||
|
tagId: string
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data - 추후 API 연동
|
||||||
|
const mockLogs: LogEntry[] = [
|
||||||
|
{ id: 1, door: '옥상출입구', type: 'IN', tagId: 'MK0100', time: '2025-12-01 14:36:12' },
|
||||||
|
{ id: 2, door: '옥상출입구', type: 'IN', tagId: 'MK0100', time: '2025-12-01 14:36:12' },
|
||||||
|
{ id: 3, door: '옥상출입구', type: 'IN', tagId: 'MK0100', time: '2025-12-01 14:36:12' },
|
||||||
|
{ id: 4, door: '서쪽출입문', type: 'OUT', tagId: 'MK0101', time: '2025-12-01 15:00:00' },
|
||||||
|
{ id: 5, door: '서쪽출입문', type: 'OUT', tagId: 'MK0101', time: '2025-12-01 15:00:00' },
|
||||||
|
{ id: 6, door: '서쪽출입문', type: 'OUT', tagId: 'MK0101', time: '2025-12-01 15:00:00' },
|
||||||
|
{ id: 7, door: '동쪽출입문', type: 'IN', tagId: 'MK0102', time: '2025-12-01 15:30:45' },
|
||||||
|
{ id: 8, door: '동쪽출입문', type: 'IN', tagId: 'MK0102', time: '2025-12-01 15:30:45' },
|
||||||
|
{ id: 9, door: '동쪽출입문', type: 'IN', tagId: 'MK0102', time: '2025-12-01 15:30:45' },
|
||||||
|
{ id: 10, door: '남쪽출입문', type: 'OUT', tagId: 'MK0103', time: '2025-12-01 16:15:20' },
|
||||||
|
{ id: 11, door: '남쪽출입문', type: 'OUT', tagId: 'MK0103', time: '2025-12-01 16:15:20' },
|
||||||
|
{ id: 12, door: '남쪽출입문', type: 'OUT', tagId: 'MK0103', time: '2025-12-01 16:15:20' },
|
||||||
|
{ id: 13, door: '옥상출입구', type: 'IN', tagId: 'MK0104', time: '2025-12-01 16:45:10' },
|
||||||
|
{ id: 14, door: '옥상출입구', type: 'IN', tagId: 'MK0104', time: '2025-12-01 16:45:10' },
|
||||||
|
{ id: 15, door: '서쪽출입문', type: 'OUT', tagId: 'MK0105', time: '2025-12-01 17:00:55' },
|
||||||
|
{ id: 16, door: '동쪽출입문', type: 'IN', tagId: 'MK0106', time: '2025-12-01 17:30:30' },
|
||||||
|
{ id: 17, door: '동쪽출입문', type: 'IN', tagId: 'MK0106', time: '2025-12-01 17:30:30' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function FloorLogPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { floorName } = useParams<{ floorName: string }>()
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Close Button */}
|
||||||
|
<button className={styles.closeButton} onClick={handleClose} aria-label="닫기">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h1 className={styles.title}>{floorName || '2F'} 출입로그</h1>
|
||||||
|
|
||||||
|
<div className={styles.logTable}>
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<span className={styles.colDoor}>출입문</span>
|
||||||
|
<span className={styles.colAccess}>출입</span>
|
||||||
|
<span className={styles.colTagId}>TagID</span>
|
||||||
|
<span className={styles.colTime}>시간</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Body */}
|
||||||
|
<div className={styles.tableBody}>
|
||||||
|
{mockLogs.map((log) => (
|
||||||
|
<div key={log.id} className={styles.tableRow}>
|
||||||
|
<span className={styles.colDoor}>{log.door}</span>
|
||||||
|
<span className={styles.colAccess}>{log.type}</span>
|
||||||
|
<span className={styles.colTagId}>{log.tagId}</span>
|
||||||
|
<span className={styles.colTime}>{log.time}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
src/pages/LoginPage.module.css
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(163deg, #010306 0.81%, #091A3F 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background blur effect - Ellipse 2327 */
|
||||||
|
.bgBlur {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
right: -234px;
|
||||||
|
top: -275px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 82.5%);
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GNB Header */
|
||||||
|
.gnb {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0px 16px 0px 24px;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
left: 0;
|
||||||
|
top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title Area */
|
||||||
|
.titleArea {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0px;
|
||||||
|
gap: 4px;
|
||||||
|
position: absolute;
|
||||||
|
width: 320px;
|
||||||
|
left: 20px;
|
||||||
|
top: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 140%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Area */
|
||||||
|
.formArea {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0px;
|
||||||
|
gap: 91px;
|
||||||
|
position: absolute;
|
||||||
|
width: 320px;
|
||||||
|
left: calc(50% - 320px/2);
|
||||||
|
top: 244px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0px;
|
||||||
|
gap: 24px;
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
89
src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { useState, FormEvent } from 'react'
|
||||||
|
import Logo from '../components/common/Logo'
|
||||||
|
import Input from '../components/common/Input'
|
||||||
|
import Button from '../components/common/Button'
|
||||||
|
import styles from './LoginPage.module.css'
|
||||||
|
|
||||||
|
interface LoginErrors {
|
||||||
|
userId?: string
|
||||||
|
password?: string
|
||||||
|
general?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [userId, setUserId] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [errors, setErrors] = useState<LoginErrors>({})
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: LoginErrors = {}
|
||||||
|
|
||||||
|
if (!userId.trim()) {
|
||||||
|
newErrors.userId = '아이디를 바르게 입력해주세요'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password.trim()) {
|
||||||
|
newErrors.password = '비밀번호를 입력해주세요'
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
return Object.keys(newErrors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: API 로그인 로직 구현
|
||||||
|
setErrors({
|
||||||
|
general: '존재하지 않는 아이디이거나\n비밀번호가 잘못 입력되었습니다'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Background blur effect */}
|
||||||
|
<div className={styles.bgBlur} />
|
||||||
|
|
||||||
|
{/* Header / GNB */}
|
||||||
|
<header className={styles.gnb}>
|
||||||
|
<Logo variant="light" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className={styles.titleArea}>
|
||||||
|
<h1 className={styles.title}>머크코리아</h1>
|
||||||
|
<h1 className={styles.title}>작업자 위치관제 시스템</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Area */}
|
||||||
|
<form className={styles.formArea} onSubmit={handleSubmit}>
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<Input
|
||||||
|
label="아이디"
|
||||||
|
placeholder="아이디를 입력해주세요"
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
|
error={errors.userId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="패스워드"
|
||||||
|
placeholder="암호를 입력해주세요"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
showPasswordToggle
|
||||||
|
error={errors.password || errors.general}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" fullWidth>
|
||||||
|
로그인
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
173
src/pages/MyInfoPage.module.css
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(159.19deg, rgb(1, 3, 6) 0.81%, rgb(9, 26, 63) 100%);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background blur effect */
|
||||||
|
.bgWrapper {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
left: 198px;
|
||||||
|
top: -275px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 63.68%);
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title Section */
|
||||||
|
.titleSection {
|
||||||
|
position: relative;
|
||||||
|
padding: 120px 20px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userId {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Area */
|
||||||
|
.formArea {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
padding: 24px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputLabel {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: linear-gradient(90deg, rgb(28, 37, 66) 0%, rgb(28, 37, 66) 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 22px 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.32px;
|
||||||
|
line-height: 27px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: #D1D9FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyeButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessage {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #BF151B;
|
||||||
|
line-height: 27px;
|
||||||
|
letter-spacing: -0.32px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password Requirements */
|
||||||
|
.requirements {
|
||||||
|
position: relative;
|
||||||
|
padding: 40px 23px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements p {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(171, 177, 230, 0.8);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit Button */
|
||||||
|
.buttonArea {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 25px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
width: 310px;
|
||||||
|
height: 60px;
|
||||||
|
background: #000000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton:active {
|
||||||
|
background: #333333;
|
||||||
|
}
|
||||||
162
src/pages/MyInfoPage.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import Header from '../components/layout/Header'
|
||||||
|
import styles from './MyInfoPage.module.css'
|
||||||
|
|
||||||
|
export default function MyInfoPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
|
||||||
|
const [showNewPassword, setShowNewPassword] = useState(false)
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||||
|
const [errors, setErrors] = useState<{
|
||||||
|
currentPassword?: string
|
||||||
|
newPassword?: string
|
||||||
|
confirmPassword?: string
|
||||||
|
}>({})
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const newErrors: typeof errors = {}
|
||||||
|
|
||||||
|
// Validate current password
|
||||||
|
if (!currentPassword) {
|
||||||
|
newErrors.currentPassword = '암호가 일치하지 않습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate new password length
|
||||||
|
if (newPassword.length < 9) {
|
||||||
|
newErrors.newPassword = '최소 아홉글자 이상을 입력해야 합니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password match
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
newErrors.confirmPassword = '암호가 일치하지 않습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length === 0) {
|
||||||
|
// Success - navigate back or show success message
|
||||||
|
alert('비밀번호가 변경되었습니다.')
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Background blur effect */}
|
||||||
|
<div className={styles.bgWrapper} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Header showMenu showBackButton logoPosition="center" />
|
||||||
|
|
||||||
|
{/* Title Section */}
|
||||||
|
<section className={styles.titleSection}>
|
||||||
|
<h1 className={styles.pageTitle}>내 정보 설정</h1>
|
||||||
|
<p className={styles.userId}>ID: admin0705</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Form Area */}
|
||||||
|
<section className={styles.formArea}>
|
||||||
|
{/* Current Password */}
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<label className={styles.inputLabel}>현재 암호를 입력해주세요</label>
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<input
|
||||||
|
type={showCurrentPassword ? 'text' : 'password'}
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="암호를 입력해주세요"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.eyeButton}
|
||||||
|
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||||
|
aria-label={showCurrentPassword ? '비밀번호 숨기기' : '비밀번호 보기'}
|
||||||
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.currentPassword && (
|
||||||
|
<p className={styles.errorMessage}>{errors.currentPassword}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Password */}
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<label className={styles.inputLabel}>새로운 암호를 입력해주세요</label>
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<input
|
||||||
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="암호를 입력해주세요"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.eyeButton}
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
aria-label={showNewPassword ? '비밀번호 숨기기' : '비밀번호 보기'}
|
||||||
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<input
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="암호를 입력해주세요"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.eyeButton}
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
aria-label={showConfirmPassword ? '비밀번호 숨기기' : '비밀번호 보기'}
|
||||||
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(errors.newPassword || errors.confirmPassword) && (
|
||||||
|
<div className={styles.errorMessages}>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className={styles.errorMessage}>{errors.confirmPassword}</p>
|
||||||
|
)}
|
||||||
|
{errors.newPassword && (
|
||||||
|
<p className={styles.errorMessage}>{errors.newPassword}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Password Requirements */}
|
||||||
|
<div className={styles.requirements}>
|
||||||
|
<p>영대문자(A~Z, 26개), 영소문자(a~z, 26개),</p>
|
||||||
|
<p>숫자(0~9, 10개) 및 특수문자(32개) 중 3종류</p>
|
||||||
|
<p>이상으로 구성하여 최소 9자 이상으로 입력합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div className={styles.buttonArea}>
|
||||||
|
<button className={styles.submitButton} onClick={handleSubmit}>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 4.375C3.75 4.375 1.25 10 1.25 10C1.25 10 3.75 15.625 10 15.625C16.25 15.625 18.75 10 18.75 10C18.75 10 16.25 4.375 10 4.375Z" stroke="#D1D9FF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M10 13.125C11.7259 13.125 13.125 11.7259 13.125 10C13.125 8.27411 11.7259 6.875 10 6.875C8.27411 6.875 6.875 8.27411 6.875 10C6.875 11.7259 8.27411 13.125 10 13.125Z" stroke="#D1D9FF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
src/pages/NonEvacueeListPage.module.css
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #FFFFFF;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 닫기 버튼 */
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제목 섹션 */
|
||||||
|
.titleSection {
|
||||||
|
padding: 50px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 32px;
|
||||||
|
color: #000000;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 테이블 컨텐츠 */
|
||||||
|
.tableContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 28px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 검색결과 테이블 */
|
||||||
|
.resultTable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCell {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #000000;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableCell {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
70
src/pages/NonEvacueeListPage.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import styles from './NonEvacueeListPage.module.css'
|
||||||
|
import closeIcon from '../assets/close-icon.svg'
|
||||||
|
|
||||||
|
// Mock data - 추후 API 연동
|
||||||
|
const nonEvacueeData = {
|
||||||
|
count: 356,
|
||||||
|
list: [
|
||||||
|
{ tagId: 'MK0100', location: '연구A동' },
|
||||||
|
{ tagId: 'MK0101', location: '연구A동' },
|
||||||
|
{ tagId: 'MK0102', location: '본관' },
|
||||||
|
{ tagId: 'MK0103', location: '휴게동' },
|
||||||
|
{ tagId: 'MK0104', location: '연구B동' },
|
||||||
|
{ tagId: 'MK0105', location: '주차장' },
|
||||||
|
{ tagId: 'MK0106', location: '연구A동' },
|
||||||
|
{ tagId: 'MK0107', location: '본관' },
|
||||||
|
{ tagId: 'MK0108', location: '휴게동' },
|
||||||
|
{ tagId: 'MK0109', location: '연구A동' },
|
||||||
|
{ tagId: 'MK0110', location: '연구B동' },
|
||||||
|
{ tagId: 'MK0111', location: '본관' },
|
||||||
|
{ tagId: 'MK0112', location: '주차장' },
|
||||||
|
{ tagId: 'MK0113', location: '연구A동' },
|
||||||
|
{ tagId: 'MK0114', location: '휴게동' },
|
||||||
|
{ tagId: 'MK0115', location: '연구B동' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NonEvacueeListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 닫기 버튼 */}
|
||||||
|
<button className={styles.closeButton} onClick={handleClose}>
|
||||||
|
<img src={closeIcon} alt="닫기" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className={styles.titleSection}>
|
||||||
|
<h1 className={styles.title}>미대피자 목록 ({nonEvacueeData.count})</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 컨텐츠 */}
|
||||||
|
<div className={styles.tableContent}>
|
||||||
|
{/* 검색결과 테이블 */}
|
||||||
|
<div className={styles.resultTable}>
|
||||||
|
{/* 테이블 헤더 */}
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<span className={styles.headerCell}>TagID</span>
|
||||||
|
<span className={styles.headerCell}>최종 위치</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 바디 */}
|
||||||
|
<div className={styles.tableBody}>
|
||||||
|
{nonEvacueeData.list.map((item, index) => (
|
||||||
|
<div key={index} className={styles.tableRow}>
|
||||||
|
<span className={styles.tableCell}>{item.tagId}</span>
|
||||||
|
<span className={styles.tableCell}>{item.location}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
215
src/pages/QRScanCompletePage.module.css
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(163deg, #010306 0.81%, #091530 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background blur effects - Figma Ellipse style */
|
||||||
|
.bgBlurTop {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
left: 198px;
|
||||||
|
top: -275px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 63.68%);
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgBlurLeft {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
left: -264px;
|
||||||
|
top: -327px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 63.68%);
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close button */
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title */
|
||||||
|
.titleContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 92px;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
color: #FFFFFF;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagId {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
color: #477BFF;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success Circle */
|
||||||
|
.successCircleContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 208px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 315px;
|
||||||
|
height: 315px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulseRing {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 185px;
|
||||||
|
height: 185px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(139deg, #77CEA3 0%, #39CD31 100%);
|
||||||
|
animation: successPulse 3.5s ease-out infinite;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes successPulse {
|
||||||
|
0% {
|
||||||
|
width: 185px;
|
||||||
|
height: 185px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
width: 350px;
|
||||||
|
height: 350px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center Circle */
|
||||||
|
.centerCircle {
|
||||||
|
position: absolute;
|
||||||
|
width: 185px;
|
||||||
|
height: 185px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(41deg, #23B91B 0%, #77C4CE 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkIcon {
|
||||||
|
width: 38px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completeText {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.32px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom buttons */
|
||||||
|
.buttonContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 588px;
|
||||||
|
left: 25px;
|
||||||
|
right: 25px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finishButton {
|
||||||
|
flex: 1;
|
||||||
|
height: 56px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #000000;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finishButton:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finishButton:active {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continueButton {
|
||||||
|
flex: 1;
|
||||||
|
height: 56px;
|
||||||
|
background: #000000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continueButton:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continueButton:active {
|
||||||
|
background: #333333;
|
||||||
|
}
|
||||||
72
src/pages/QRScanCompletePage.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import styles from './QRScanCompletePage.module.css'
|
||||||
|
import checkIcon from '../assets/check-icon.svg'
|
||||||
|
|
||||||
|
export default function QRScanCompletePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const tagId = location.state?.tagId || 'TF-003'
|
||||||
|
|
||||||
|
const handleFinish = () => {
|
||||||
|
navigate('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
navigate('/qr-scan')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
navigate('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Background blur effects */}
|
||||||
|
<div className={styles.bgBlurTop} />
|
||||||
|
<div className={styles.bgBlurLeft} />
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<button className={styles.closeButton} onClick={handleClose} aria-label="닫기">
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className={styles.titleContainer}>
|
||||||
|
<h1 className={styles.title}>태그 인식 완료</h1>
|
||||||
|
<p className={styles.tagId}>TagID : {tagId}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Circle with Pulse Animation */}
|
||||||
|
<div className={styles.successCircleContainer}>
|
||||||
|
{/* Pulse Animation */}
|
||||||
|
<div className={styles.pulseRing} />
|
||||||
|
<div className={styles.pulseRing} style={{ animationDelay: '1s' }} />
|
||||||
|
<div className={styles.pulseRing} style={{ animationDelay: '2s' }} />
|
||||||
|
|
||||||
|
{/* Center Circle */}
|
||||||
|
<div className={styles.centerCircle}>
|
||||||
|
<img src={checkIcon} alt="완료" className={styles.checkIcon} />
|
||||||
|
<span className={styles.completeText}>완료 되었습니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom buttons */}
|
||||||
|
<div className={styles.buttonContainer}>
|
||||||
|
<button className={styles.finishButton} onClick={handleFinish}>
|
||||||
|
끝내기
|
||||||
|
</button>
|
||||||
|
<button className={styles.continueButton} onClick={handleContinue}>
|
||||||
|
계속 태깅
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
src/pages/QRScanPage.module.css
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(163deg, #010306 0.81%, #091530 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popup overlay - 배경 제거, container에서 처리 */
|
||||||
|
.popup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background blur effects - Figma Ellipse style */
|
||||||
|
.bgBlurTop {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
left: 198px;
|
||||||
|
top: -275px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 63.68%);
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgBlurLeft {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
left: -264px;
|
||||||
|
top: -327px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 63.68%);
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close button */
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title */
|
||||||
|
.titleContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 92px;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
color: #FFFFFF;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Illustration */
|
||||||
|
.illustrationContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 180px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 320px;
|
||||||
|
height: 360px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.illustration {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom buttons */
|
||||||
|
.buttonContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 588px;
|
||||||
|
left: 25px;
|
||||||
|
right: 25px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton {
|
||||||
|
flex: 1;
|
||||||
|
height: 56px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #000000;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton:active {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmButton {
|
||||||
|
flex: 1;
|
||||||
|
height: 56px;
|
||||||
|
background: #000000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmButton:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmButton:active {
|
||||||
|
background: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation */
|
||||||
|
.pulseContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 244px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulseRing {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(139deg, #7798CE 0%, #3C5BCD 100%);
|
||||||
|
animation: pulse 3.5s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
width: 350px;
|
||||||
|
height: 350px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/pages/QRScanPage.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import styles from './QRScanPage.module.css'
|
||||||
|
import qrIllustrationImg from '../assets/qr-illustration.png'
|
||||||
|
|
||||||
|
export default function QRScanPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
// QR 스캔 완료 페이지로 이동
|
||||||
|
navigate('/qr-scan/complete', { state: { tagId: 'TF-003' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Background blur effects */}
|
||||||
|
<div className={styles.bgBlurTop} />
|
||||||
|
<div className={styles.bgBlurLeft} />
|
||||||
|
|
||||||
|
{/* Popup container */}
|
||||||
|
<div className={styles.popup} />
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<button className={styles.closeButton} onClick={handleClose} aria-label="닫기">
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className={styles.titleContainer}>
|
||||||
|
<h1 className={styles.title}>
|
||||||
|
QR코드를<br />
|
||||||
|
인식시켜 주세요
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pulse Animation */}
|
||||||
|
<div className={styles.pulseContainer}>
|
||||||
|
<div className={styles.pulseRing} />
|
||||||
|
<div className={styles.pulseRing} style={{ animationDelay: '1s' }} />
|
||||||
|
<div className={styles.pulseRing} style={{ animationDelay: '2s' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Phone Illustration */}
|
||||||
|
<div className={styles.illustrationContainer}>
|
||||||
|
<img
|
||||||
|
src={qrIllustrationImg}
|
||||||
|
alt="QR 코드 스캔"
|
||||||
|
className={styles.illustration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom buttons */}
|
||||||
|
<div className={styles.buttonContainer}>
|
||||||
|
<button className={styles.cancelButton} onClick={handleCancel}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button className={styles.confirmButton} onClick={handleConfirm}>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
src/pages/TagBatteryListPage.module.css
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #FFFFFF;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
padding-top: 50px;
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title Section */
|
||||||
|
.titleSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 32px;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
color: #000000;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #111111;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List Section */
|
||||||
|
.listSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalCount {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #477BFF;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerTagId {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #000000;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDate {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #000000;
|
||||||
|
text-align: center;
|
||||||
|
width: 135px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
max-height: 448px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellTagId {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #444444;
|
||||||
|
width: 60px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellDate {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #444444;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
116
src/pages/TagBatteryListPage.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import styles from './TagBatteryListPage.module.css'
|
||||||
|
|
||||||
|
interface TagEntry {
|
||||||
|
id: number
|
||||||
|
tagId: string
|
||||||
|
registeredAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data - 추후 API 연동
|
||||||
|
const mockLowBatteryTags: TagEntry[] = [
|
||||||
|
{ id: 1, tagId: 'P001', registeredAt: '2025-11-24 15:26:35' },
|
||||||
|
{ id: 2, tagId: 'P005', registeredAt: '2025-11-24 15:26:35' },
|
||||||
|
{ id: 3, tagId: 'P006', registeredAt: '2025-11-24 15:30:00' },
|
||||||
|
{ id: 4, tagId: 'P006', registeredAt: '2025-11-24 15:30:00' },
|
||||||
|
{ id: 5, tagId: 'P008', registeredAt: '2025-11-24 15:40:25' },
|
||||||
|
{ id: 6, tagId: 'P009', registeredAt: '2025-11-25 10:30:00' },
|
||||||
|
{ id: 7, tagId: 'P010', registeredAt: '2025-11-26 09:15:45' },
|
||||||
|
{ id: 8, tagId: 'P011', registeredAt: '2025-11-24 15:55:30' },
|
||||||
|
{ id: 9, tagId: 'P012', registeredAt: '2025-11-24 16:00:45' },
|
||||||
|
{ id: 10, tagId: 'P013', registeredAt: '2025-11-24 16:05:00' },
|
||||||
|
{ id: 11, tagId: 'P013', registeredAt: '2025-11-24 16:05:00' },
|
||||||
|
{ id: 12, tagId: 'P013', registeredAt: '2025-11-24 16:05:00' },
|
||||||
|
{ id: 13, tagId: 'P013', registeredAt: '2025-11-24 16:05:00' },
|
||||||
|
{ id: 14, tagId: 'P013', registeredAt: '2025-11-24 16:05:00' },
|
||||||
|
{ id: 15, tagId: 'P013', registeredAt: '2025-11-24 16:05:00' },
|
||||||
|
{ id: 16, tagId: 'P013', registeredAt: '2025-11-24 16:05:00' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockHighBatteryTags: TagEntry[] = [
|
||||||
|
{ id: 1, tagId: 'H001', registeredAt: '2025-11-24 10:00:00' },
|
||||||
|
{ id: 2, tagId: 'H002', registeredAt: '2025-11-24 10:15:00' },
|
||||||
|
{ id: 3, tagId: 'H003', registeredAt: '2025-11-24 10:30:00' },
|
||||||
|
{ id: 4, tagId: 'H004', registeredAt: '2025-11-24 11:00:00' },
|
||||||
|
{ id: 5, tagId: 'H005', registeredAt: '2025-11-24 11:30:00' },
|
||||||
|
{ id: 6, tagId: 'H006', registeredAt: '2025-11-24 12:00:00' },
|
||||||
|
{ id: 7, tagId: 'H007', registeredAt: '2025-11-24 12:30:00' },
|
||||||
|
{ id: 8, tagId: 'H008', registeredAt: '2025-11-24 13:00:00' },
|
||||||
|
{ id: 9, tagId: 'H009', registeredAt: '2025-11-24 13:30:00' },
|
||||||
|
{ id: 10, tagId: 'H010', registeredAt: '2025-11-24 14:00:00' },
|
||||||
|
{ id: 11, tagId: 'H011', registeredAt: '2025-11-24 14:30:00' },
|
||||||
|
{ id: 12, tagId: 'H012', registeredAt: '2025-11-24 15:00:00' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const pageConfig = {
|
||||||
|
low: {
|
||||||
|
title: 'Low Battery',
|
||||||
|
description: '남은 전력이 25% 이하로\n배터리 교체 대상인 태그의 목록입니다',
|
||||||
|
data: mockLowBatteryTags,
|
||||||
|
totalCount: 1376,
|
||||||
|
},
|
||||||
|
high: {
|
||||||
|
title: 'High Battery',
|
||||||
|
description: '배터리의 남은 전력이 25% 를 초과하는\n태그의 목록입니다',
|
||||||
|
data: mockHighBatteryTags,
|
||||||
|
totalCount: 1376,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagBatteryListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { type } = useParams<{ type: 'low' | 'high' }>()
|
||||||
|
|
||||||
|
const config = pageConfig[type as 'low' | 'high'] || pageConfig.low
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Close Button */}
|
||||||
|
<button className={styles.closeButton} onClick={handleClose} aria-label="닫기">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6L18 18" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={styles.content}>
|
||||||
|
{/* Title Section */}
|
||||||
|
<div className={styles.titleSection}>
|
||||||
|
<h1 className={styles.title}>{config.title}</h1>
|
||||||
|
<p className={styles.description}>{config.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Section */}
|
||||||
|
<div className={styles.listSection}>
|
||||||
|
{/* Total Count */}
|
||||||
|
<div className={styles.countRow}>
|
||||||
|
<span className={styles.totalCount}>총 {config.totalCount.toLocaleString()}개</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className={styles.table}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<span className={styles.headerTagId}>TagID</span>
|
||||||
|
<span className={styles.headerDate}>등록일</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className={styles.tableBody}>
|
||||||
|
{config.data.map((entry) => (
|
||||||
|
<div key={entry.id} className={styles.tableRow}>
|
||||||
|
<span className={styles.cellTagId}>{entry.tagId}</span>
|
||||||
|
<span className={styles.cellDate}>{entry.registeredAt}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
132
src/pages/TagBatteryPage.module.css
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: linear-gradient(163.03deg, rgb(1, 3, 6) 0.81%, rgb(9, 26, 63) 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background blur effect - Figma Ellipse 2327 */
|
||||||
|
.bgWrapper {
|
||||||
|
position: absolute;
|
||||||
|
width: 426px;
|
||||||
|
height: 409px;
|
||||||
|
left: 198px;
|
||||||
|
top: -275px;
|
||||||
|
background: linear-gradient(228.57deg, rgba(119, 152, 206, 0.8) 20.5%, rgba(79, 72, 176, 0.8) 63.68%);
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title Section */
|
||||||
|
.titleSection {
|
||||||
|
position: relative;
|
||||||
|
padding: 120px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards Section */
|
||||||
|
.cardsSection {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Battery Card */
|
||||||
|
.batteryCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
background: linear-gradient(120deg, rgba(58, 69, 123, 0.64) 2%, rgba(36, 44, 85, 0.32) 95%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batteryLabel {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
opacity: 0.8;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batteryCount {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batteryPercentage {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
letter-spacing: -0.56px;
|
||||||
|
text-align: right;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDivider {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listButtonText {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
89
src/pages/TagBatteryPage.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import Header from '../components/layout/Header'
|
||||||
|
import styles from './TagBatteryPage.module.css'
|
||||||
|
import highBatteryIcon from '../assets/battery_high.svg'
|
||||||
|
import lowBatteryIcon from '../assets/battery_low.svg'
|
||||||
|
|
||||||
|
// 배터리 데이터 (추후 API 연동)
|
||||||
|
const batteryData = {
|
||||||
|
high: { count: 1686, percentage: 61 },
|
||||||
|
low: { count: 1316, percentage: 39 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagBatteryPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Background blur effect - Figma Ellipse 2327 */}
|
||||||
|
<div className={styles.bgWrapper} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Header showMenu showBackButton logoPosition="center" />
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<section className={styles.titleSection}>
|
||||||
|
<h1 className={styles.pageTitle}>태그 배터리</h1>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Battery Cards */}
|
||||||
|
<section className={styles.cardsSection}>
|
||||||
|
{/* High Battery Card */}
|
||||||
|
<div className={styles.batteryCard}>
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
<div className={styles.cardLeft}>
|
||||||
|
<BatteryIcon level="high" />
|
||||||
|
<div className={styles.cardInfo}>
|
||||||
|
<span className={styles.batteryLabel}>High Battery</span>
|
||||||
|
<span className={styles.batteryCount}>{batteryData.high.count.toLocaleString()}개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.batteryPercentage}>{batteryData.high.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.cardDivider} />
|
||||||
|
<button className={styles.listButton} onClick={() => navigate('/tag-battery/high')}>
|
||||||
|
<span className={styles.listButtonText}>리스트보기</span>
|
||||||
|
<ChevronIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Low Battery Card */}
|
||||||
|
<div className={styles.batteryCard}>
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
<div className={styles.cardLeft}>
|
||||||
|
<BatteryIcon level="low" />
|
||||||
|
<div className={styles.cardInfo}>
|
||||||
|
<span className={styles.batteryLabel}>Low Battery</span>
|
||||||
|
<span className={styles.batteryCount}>{batteryData.low.count.toLocaleString()}개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.batteryPercentage}>{batteryData.low.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.cardDivider} />
|
||||||
|
<button className={styles.listButton} onClick={() => navigate('/tag-battery/low')}>
|
||||||
|
<span className={styles.listButtonText}>리스트보기</span>
|
||||||
|
<ChevronIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatteryIcon({ level }: { level: 'high' | 'low' }) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={level === 'high' ? highBatteryIcon : lowBatteryIcon}
|
||||||
|
alt={`${level} battery`}
|
||||||
|
style={{ width: 24, height: 39 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 7L17 14L10 21" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/styles/global.css
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Background */
|
||||||
|
--bg-gradient: linear-gradient(163deg, #010306 0.81%, #091A3F 100%);
|
||||||
|
--bg-input: #1C2542;
|
||||||
|
|
||||||
|
/* Colors */
|
||||||
|
--color-primary: #FFFFFF;
|
||||||
|
--color-error: #BF151B;
|
||||||
|
--color-error-text: #FF0000;
|
||||||
|
--color-success: #17A200;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #FFFFFF;
|
||||||
|
--text-placeholder: #D1D9FF;
|
||||||
|
--text-secondary: rgba(255, 255, 255, 0.8);
|
||||||
|
--text-gray: #686868;
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
--btn-primary: #000000;
|
||||||
|
|
||||||
|
/* Menu */
|
||||||
|
--bg-menu: #FFFFFF;
|
||||||
|
--text-menu-primary: #000000;
|
||||||
|
--text-menu-secondary: #686868;
|
||||||
|
--border-menu: #E5E5E5;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius: 8px;
|
||||||
|
|
||||||
|
/* Font */
|
||||||
|
--font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
24
tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
tsconfig.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/common/button.tsx","./src/components/common/input.tsx","./src/components/common/logo.tsx","./src/components/dashboard/batteryhealthcard.tsx","./src/components/dashboard/buildingcard.tsx","./src/components/facility/categorytab.tsx","./src/components/facility/entrycard.tsx","./src/components/facility/facilitycard.tsx","./src/components/facility/floorsection.tsx","./src/components/layout/header.tsx","./src/components/layout/sidemenu.tsx","./src/pages/accesslogpage.tsx","./src/pages/alllogpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/emergencyalertpage.tsx","./src/pages/emergencyfirepage.tsx","./src/pages/evacueelistpage.tsx","./src/pages/facilitydetailpage.tsx","./src/pages/facilitystatuspage.tsx","./src/pages/floorlogpage.tsx","./src/pages/loginpage.tsx","./src/pages/myinfopage.tsx","./src/pages/nonevacueelistpage.tsx","./src/pages/qrscancompletepage.tsx","./src/pages/qrscanpage.tsx","./src/pages/tagbatterylistpage.tsx","./src/pages/tagbatterypage.tsx"],"version":"5.6.3"}
|
||||||