design: Mobile Publish

This commit is contained in:
qjarl5678 2026-01-22 22:12:34 +09:00
commit 301040973b
101 changed files with 7522 additions and 0 deletions

25
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

BIN
app-design/MK-001_CASE.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
app-design/MK-001_MENU.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
app-design/MK-002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

BIN
app-design/MK-003-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

BIN
app-design/MK-003-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
app-design/MK-003-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
app-design/MK-003-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
app-design/MK-003.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

BIN
app-design/MK-005.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
app-design/MK-006-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
app-design/MK-006-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
app-design/MK-006.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

BIN
app-design/MK-007-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

BIN
app-design/MK-007-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
app-design/MK-010-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

BIN
app-design/MK-010-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
app-design/MK-010-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
app-design/MK-011.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

13
index.html Normal file
View 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>

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

21
nginx.conf Normal file
View 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

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View 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
View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 188 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 183 KiB

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

3
src/assets/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

18
src/assets/mk005-bg.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View 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

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

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

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

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

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

File diff suppressed because one or more lines are too long

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

24
tsconfig.json Normal file
View 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
View 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"}

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