feat: 실시간 대기 번호표 웹앱 초기 커밋
PIN 기반 세션으로 관리자·발급기·표시판 3개 역할을 하나의 앱에서 운영. Socket.IO 실시간 동기화, Podman Quadlet(10.89.0.25) 배포 구성 포함. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
12
Containerfile
Normal file
12
Containerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY server.js ./
|
||||||
|
COPY public/ ./public/
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
168
README.md
Normal file
168
README.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# 대기 번호표 시스템
|
||||||
|
|
||||||
|
실시간 대기 번호표 발급·관리·표시 웹앱입니다.
|
||||||
|
하나의 URL로 세 가지 역할(관리자 / 발급기 / 표시판)을 모두 운영합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
관리자 디바이스 → 번호 호출 / 초기화
|
||||||
|
발급기 디바이스 → 고객이 번호표 수령
|
||||||
|
표시판 디바이스 → 호출 번호 대형 화면 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
모든 디바이스는 **PIN**으로 같은 세션에 묶입니다.
|
||||||
|
같은 PIN이면 관리자 2대 + 표시판 2대처럼 여러 대를 동시에 운영할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시작하기
|
||||||
|
|
||||||
|
### 1. 첫 접속 (PIN 생성)
|
||||||
|
|
||||||
|
앱 URL에 접속하면 PIN 입력 화면이 나옵니다.
|
||||||
|
|
||||||
|
- 원하는 PIN을 자유롭게 입력합니다 (숫자 권장, 최소 4자리).
|
||||||
|
- 처음 입력하는 PIN은 자동으로 새 세션을 생성합니다.
|
||||||
|
- **접속** 버튼을 누르거나 Enter를 치면 역할 선택 화면으로 이동합니다.
|
||||||
|
|
||||||
|
> 같은 PIN을 다른 디바이스에서 입력하면 동일한 세션에 합류합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 역할 선택
|
||||||
|
|
||||||
|
| 역할 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| ⚙️ **관리자** | 번호 호출·초기화를 담당하는 직원용 화면 |
|
||||||
|
| 🎟️ **번호표 발급기** | 고객이 직접 번호를 뽑는 키오스크 화면 |
|
||||||
|
| 📺 **번호 표시판** | 대기실에 띄워두는 대형 번호 화면 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 화면별 사용법
|
||||||
|
|
||||||
|
### ⚙️ 관리자 화면
|
||||||
|
|
||||||
|
![관리자 화면]
|
||||||
|
|
||||||
|
| 항목 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| 현재 호출 번호 | 지금 표시판에 표시 중인 번호 |
|
||||||
|
| 대기 중 | 아직 호출되지 않은 인원 수 |
|
||||||
|
| 발급 총계 | 지금까지 발급된 번호표 총 수 |
|
||||||
|
|
||||||
|
**다음 호출 ▶** 버튼을 누르면
|
||||||
|
- 다음 번호가 호출됩니다.
|
||||||
|
- 표시판 화면이 즉시 업데이트됩니다.
|
||||||
|
- 대기 인원이 1 감소합니다.
|
||||||
|
- 대기자가 없으면 버튼이 비활성화됩니다.
|
||||||
|
|
||||||
|
**번호 초기화** 버튼을 누르면
|
||||||
|
- 확인 팝업이 뜨고 확인 시 모든 번호가 0으로 초기화됩니다.
|
||||||
|
- 발급기·표시판도 동시에 초기화됩니다.
|
||||||
|
|
||||||
|
**역할 변경** 버튼으로 역할 선택 화면으로 돌아갈 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🎟️ 번호표 발급기 화면
|
||||||
|
|
||||||
|
고객이 직접 터치하는 키오스크 화면입니다.
|
||||||
|
|
||||||
|
- 화면 상단에 **현재 대기 인원**과 **현재 호출 번호**가 실시간으로 표시됩니다.
|
||||||
|
- **번호표 뽑기** 버튼을 누르면 번호가 발급되고 초록색 박스로 표시됩니다.
|
||||||
|
- 발급 후에는 **새 번호표 뽑기** 버튼으로 다음 고객의 번호를 발급할 수 있습니다.
|
||||||
|
- 관리자가 번호를 호출하면 "앞에 N명 대기 중" 문구가 자동으로 업데이트됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📺 번호 표시판 화면
|
||||||
|
|
||||||
|
대기실 모니터에 띄워두는 화면입니다.
|
||||||
|
|
||||||
|
- 호출된 번호가 전체 화면 크기로 크게 표시됩니다.
|
||||||
|
- 새 번호가 호출될 때마다 **노란색 플래시 애니메이션**이 재생됩니다.
|
||||||
|
- 아직 호출된 번호가 없으면 `-`로 표시됩니다.
|
||||||
|
- 우측 하단의 **역할 변경** 버튼으로 돌아갈 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다중 디바이스 운영
|
||||||
|
|
||||||
|
같은 PIN으로 접속하면 역할에 관계없이 몇 대든 연결 가능합니다.
|
||||||
|
|
||||||
|
**예시: 2창구 운영**
|
||||||
|
```
|
||||||
|
관리자 A (창구 1) ─┐
|
||||||
|
관리자 B (창구 2) ─┤ PIN: 1234 ─── 표시판 A (대기실 TV 1)
|
||||||
|
발급기 ─┤ ── 표시판 B (대기실 TV 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 관리자 A, B 중 누가 "다음 호출"을 눌러도 표시판 A, B 모두 즉시 업데이트됩니다.
|
||||||
|
- 두 관리자가 동시에 누르지 않도록 운영 상 협의가 필요합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배포 구성
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 컨테이너 IP | `10.89.0.25` |
|
||||||
|
| 내부 포트 | `3000` |
|
||||||
|
| 이미지 | `localhost/queue-app:latest` |
|
||||||
|
| Quadlet | `/etc/containers/systemd/queue.container` |
|
||||||
|
| 네트워크 | `proxy.network` |
|
||||||
|
|
||||||
|
### 빌드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/queue-app
|
||||||
|
npm install # package-lock.json 생성
|
||||||
|
podman build -t queue-app:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서비스 관리
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 시작
|
||||||
|
systemctl start queue.service
|
||||||
|
|
||||||
|
# 중지
|
||||||
|
systemctl stop queue.service
|
||||||
|
|
||||||
|
# 상태 확인
|
||||||
|
systemctl status queue.service
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
journalctl -u queue.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 업데이트 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/queue-app
|
||||||
|
podman build -t queue-app:latest .
|
||||||
|
systemctl restart queue.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### NPM(Nginx Proxy Manager) 설정
|
||||||
|
|
||||||
|
Proxy Host 추가 시 **Websockets Support를 반드시 활성화**해야 합니다.
|
||||||
|
Socket.IO 실시간 통신이 WebSocket 업그레이드를 사용하기 때문입니다.
|
||||||
|
|
||||||
|
- Forward Hostname: `10.89.0.25`
|
||||||
|
- Forward Port: `3000`
|
||||||
|
- ☑ Websockets Support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
- **런타임**: Node.js 20 (Alpine)
|
||||||
|
- **프레임워크**: Express 4
|
||||||
|
- **실시간 통신**: Socket.IO 4
|
||||||
|
- **상태 저장**: 서버 메모리 (재시작 시 초기화)
|
||||||
|
- **컨테이너**: Podman Quadlet (systemd 관리)
|
||||||
1087
package-lock.json
generated
Normal file
1087
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "queue-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"socket.io": "^4.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
456
public/index.html
Normal file
456
public/index.html
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>대기 번호표</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--radius: 16px;
|
||||||
|
--shadow: 0 4px 24px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', 'Noto Sans KR', Arial, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SCREENS ── */
|
||||||
|
.screen {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 24px 16px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.screen.active { display: flex; }
|
||||||
|
|
||||||
|
/* ── CARD ── */
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 32px 28px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h1 { font-size: 1.5rem; font-weight: 700; color: #1a1a2e; margin-bottom: 8px; }
|
||||||
|
.card p { color: #666; font-size: .95rem; margin-bottom: 20px; line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ── INPUTS ── */
|
||||||
|
input[type="text"], input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 10px;
|
||||||
|
outline: none;
|
||||||
|
transition: border .2s;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
input:focus { border-color: #4f46e5; }
|
||||||
|
.error-msg { color: #e53e3e; font-size: .85rem; margin-top: 8px; min-height: 1.2em; }
|
||||||
|
|
||||||
|
/* ── BUTTONS ── */
|
||||||
|
.btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .1s, opacity .2s;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(.97); }
|
||||||
|
.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
|
||||||
|
|
||||||
|
.btn-primary { background: #4f46e5; color: #fff; }
|
||||||
|
.btn-success { background: #22c55e; color: #fff; }
|
||||||
|
.btn-danger { background: #ef4444; color: #fff; }
|
||||||
|
.btn-secondary { background: #f1f5f9; color: #333; }
|
||||||
|
|
||||||
|
/* ── ROLE CARDS ── */
|
||||||
|
.role-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.role-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 24px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.role-card:hover { border-color: #4f46e5; }
|
||||||
|
.role-card:active { transform: scale(.98); }
|
||||||
|
.role-icon { font-size: 2.4rem; flex-shrink: 0; }
|
||||||
|
.role-text h3 { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
|
||||||
|
.role-text p { font-size: .85rem; color: #777; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── STAT BADGE ── */
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
.stat-val { font-size: 1.8rem; font-weight: 800; color: #4f46e5; }
|
||||||
|
.stat-lbl { font-size: .75rem; color: #888; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── ADMIN SCREEN ── */
|
||||||
|
#screen-admin { background: #eef2ff; }
|
||||||
|
#screen-admin .card h1 { color: #4f46e5; }
|
||||||
|
.admin-current {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin: 12px 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ISSUE SCREEN ── */
|
||||||
|
#screen-issue { background: #f0fdf4; }
|
||||||
|
.my-ticket-box {
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.my-ticket-box .tnum { font-size: 4.5rem; font-weight: 900; line-height: 1; }
|
||||||
|
.my-ticket-box .tlbl { font-size: .9rem; opacity: .85; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ── DISPLAY SCREEN ── */
|
||||||
|
#screen-display {
|
||||||
|
background: #0f172a;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.display-label {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.display-num {
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: clamp(6rem, 25vw, 16rem);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
transition: color .2s;
|
||||||
|
}
|
||||||
|
.display-num.flash {
|
||||||
|
animation: flash-anim .6s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes flash-anim {
|
||||||
|
0% { color: #facc15; transform: scale(1.05); }
|
||||||
|
60% { color: #facc15; }
|
||||||
|
100% { color: #f8fafc; transform: scale(1); }
|
||||||
|
}
|
||||||
|
.display-sub {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.pin-badge {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px; right: 16px;
|
||||||
|
background: rgba(255,255,255,.08);
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: .75rem;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
#screen-display .btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
width: auto;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: .85rem;
|
||||||
|
background: rgba(255,255,255,.08);
|
||||||
|
color: #94a3b8;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HEADER PIN ── */
|
||||||
|
.session-header {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 8px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ══════════════════ PIN 입력 ══════════════════ -->
|
||||||
|
<div id="screen-pin" class="screen active">
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:2.5rem;margin-bottom:12px">🎫</div>
|
||||||
|
<h1>대기 번호표</h1>
|
||||||
|
<p>세션 PIN을 입력하세요.<br>없으면 새 PIN을 만들어 접속하세요.</p>
|
||||||
|
<input type="password" id="pin-input" placeholder="PIN 입력 (4자리 이상)"
|
||||||
|
maxlength="12" inputmode="numeric" autocomplete="off">
|
||||||
|
<div class="error-msg" id="pin-error"></div>
|
||||||
|
<button class="btn btn-primary" id="pin-btn">접속</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════════════ 역할 선택 ══════════════════ -->
|
||||||
|
<div id="screen-role" class="screen">
|
||||||
|
<div class="session-header" id="role-pin-label">PIN: ----</div>
|
||||||
|
<div class="role-grid">
|
||||||
|
<div class="role-card" id="role-admin">
|
||||||
|
<div class="role-icon">⚙️</div>
|
||||||
|
<div class="role-text">
|
||||||
|
<h3>관리자</h3>
|
||||||
|
<p>번호 호출 및 초기화 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="role-card" id="role-issue">
|
||||||
|
<div class="role-icon">🎟️</div>
|
||||||
|
<div class="role-text">
|
||||||
|
<h3>번호표 발급기</h3>
|
||||||
|
<p>대기 번호표 발급 키오스크</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="role-card" id="role-display">
|
||||||
|
<div class="role-icon">📺</div>
|
||||||
|
<div class="role-text">
|
||||||
|
<h3>번호 표시판</h3>
|
||||||
|
<p>현재 호출 번호 대형 화면 표시</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════════════ 관리자 ══════════════════ -->
|
||||||
|
<div id="screen-admin" class="screen">
|
||||||
|
<div class="card" style="max-width:460px">
|
||||||
|
<p class="session-header" id="admin-pin-label">PIN: ----</p>
|
||||||
|
<h1>⚙️ 관리자</h1>
|
||||||
|
<p>현재 호출 번호</p>
|
||||||
|
<div class="admin-current" id="admin-current">-</div>
|
||||||
|
<hr class="divider">
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-val" id="admin-waiting">0</div>
|
||||||
|
<div class="stat-lbl">대기 중</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-val" id="admin-issued">0</div>
|
||||||
|
<div class="stat-lbl">발급 총계</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="admin-next-btn">다음 호출 ▶</button>
|
||||||
|
<button class="btn btn-danger" id="admin-reset-btn" style="margin-top:8px">번호 초기화</button>
|
||||||
|
<button class="btn btn-secondary" id="admin-back-btn" style="margin-top:8px;font-size:.85rem">역할 변경</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════════════ 발급기 ══════════════════ -->
|
||||||
|
<div id="screen-issue" class="screen">
|
||||||
|
<div class="card">
|
||||||
|
<p class="session-header" id="issue-pin-label">PIN: ----</p>
|
||||||
|
<h1>🎟️ 번호표 발급</h1>
|
||||||
|
<div class="stat-row" style="margin-bottom:8px">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-val" id="issue-waiting">0</div>
|
||||||
|
<div class="stat-lbl">현재 대기 인원</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-val" id="issue-called">-</div>
|
||||||
|
<div class="stat-lbl">현재 호출 번호</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="issue-ticket-box" style="display:none" class="my-ticket-box">
|
||||||
|
<div class="tlbl">내 번호표</div>
|
||||||
|
<div class="tnum" id="issue-my-num">-</div>
|
||||||
|
<div class="tlbl" id="issue-my-wait"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-success" id="issue-btn">번호표 뽑기</button>
|
||||||
|
<button class="btn btn-secondary" id="issue-back-btn" style="margin-top:8px;font-size:.85rem">역할 변경</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════════════ 표시판 ══════════════════ -->
|
||||||
|
<div id="screen-display" class="screen">
|
||||||
|
<div class="pin-badge" id="display-pin-label">----</div>
|
||||||
|
<div class="display-label">현재 호출 번호</div>
|
||||||
|
<div class="display-num" id="display-num">-</div>
|
||||||
|
<div class="display-sub" id="display-sub">대기 중 0명</div>
|
||||||
|
<button class="btn" id="display-back-btn">역할 변경</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script>
|
||||||
|
const socket = io();
|
||||||
|
let currentPin = '';
|
||||||
|
let currentRole = '';
|
||||||
|
let myTicketNumber = null;
|
||||||
|
|
||||||
|
// ── 유틸 ──────────────────────────────────────
|
||||||
|
function showScreen(id) {
|
||||||
|
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||||
|
document.getElementById(id).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(n) { return n > 0 ? String(n) : '-'; }
|
||||||
|
|
||||||
|
// ── PIN 화면 ──────────────────────────────────
|
||||||
|
const pinInput = document.getElementById('pin-input');
|
||||||
|
const pinError = document.getElementById('pin-error');
|
||||||
|
|
||||||
|
document.getElementById('pin-btn').addEventListener('click', tryJoin);
|
||||||
|
pinInput.addEventListener('keydown', e => { if (e.key === 'Enter') tryJoin(); });
|
||||||
|
|
||||||
|
function tryJoin() {
|
||||||
|
const v = pinInput.value.trim();
|
||||||
|
if (v.length < 4) { pinError.textContent = 'PIN은 4자리 이상이어야 합니다.'; return; }
|
||||||
|
pinError.textContent = '';
|
||||||
|
socket.emit('join', { pin: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('join_error', msg => { pinError.textContent = msg; });
|
||||||
|
|
||||||
|
socket.on('join_ok', state => {
|
||||||
|
currentPin = pinInput.value.trim();
|
||||||
|
document.getElementById('role-pin-label').textContent = `PIN: ${currentPin}`;
|
||||||
|
document.getElementById('admin-pin-label').textContent = `PIN: ${currentPin}`;
|
||||||
|
document.getElementById('issue-pin-label').textContent = `PIN: ${currentPin}`;
|
||||||
|
document.getElementById('display-pin-label').textContent = `PIN: ${currentPin}`;
|
||||||
|
applyState(state);
|
||||||
|
showScreen('screen-role');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 역할 선택 ──────────────────────────────────
|
||||||
|
document.getElementById('role-admin').addEventListener('click', () => {
|
||||||
|
currentRole = 'admin'; showScreen('screen-admin');
|
||||||
|
});
|
||||||
|
document.getElementById('role-issue').addEventListener('click', () => {
|
||||||
|
currentRole = 'issue'; myTicketNumber = null;
|
||||||
|
document.getElementById('issue-ticket-box').style.display = 'none';
|
||||||
|
document.getElementById('issue-btn').disabled = false;
|
||||||
|
document.getElementById('issue-btn').textContent = '번호표 뽑기';
|
||||||
|
showScreen('screen-issue');
|
||||||
|
});
|
||||||
|
document.getElementById('role-display').addEventListener('click', () => {
|
||||||
|
currentRole = 'display'; showScreen('screen-display');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 역할 변경
|
||||||
|
document.getElementById('admin-back-btn').addEventListener('click', () => showScreen('screen-role'));
|
||||||
|
document.getElementById('issue-back-btn').addEventListener('click', () => showScreen('screen-role'));
|
||||||
|
document.getElementById('display-back-btn').addEventListener('click', () => showScreen('screen-role'));
|
||||||
|
|
||||||
|
// ── 관리자 버튼 ──────────────────────────────────
|
||||||
|
document.getElementById('admin-next-btn').addEventListener('click', () => {
|
||||||
|
socket.emit('call_next');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('admin-reset-btn').addEventListener('click', () => {
|
||||||
|
if (confirm('번호를 초기화하시겠습니까?\n발급된 번호가 모두 0으로 돌아갑니다.')) {
|
||||||
|
socket.emit('reset');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 발급 버튼 ──────────────────────────────────
|
||||||
|
document.getElementById('issue-btn').addEventListener('click', () => {
|
||||||
|
socket.emit('issue_ticket');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 상태 갱신 ──────────────────────────────────
|
||||||
|
function applyState(s) {
|
||||||
|
// 관리자
|
||||||
|
document.getElementById('admin-current').textContent = fmtNum(s.calledCount);
|
||||||
|
document.getElementById('admin-waiting').textContent = s.waitingCount;
|
||||||
|
document.getElementById('admin-issued').textContent = s.issuedCount;
|
||||||
|
document.getElementById('admin-next-btn').disabled = s.waitingCount === 0;
|
||||||
|
|
||||||
|
// 발급기
|
||||||
|
document.getElementById('issue-waiting').textContent = s.waitingCount;
|
||||||
|
document.getElementById('issue-called').textContent = fmtNum(s.calledCount);
|
||||||
|
if (myTicketNumber !== null) {
|
||||||
|
const ahead = myTicketNumber - s.calledCount;
|
||||||
|
document.getElementById('issue-my-wait').textContent =
|
||||||
|
ahead > 0 ? `앞에 ${ahead}명 대기 중` : '곧 호출됩니다!';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 표시판
|
||||||
|
const displayEl = document.getElementById('display-num');
|
||||||
|
const newVal = fmtNum(s.calledCount);
|
||||||
|
if (displayEl.textContent !== newVal) {
|
||||||
|
displayEl.classList.remove('flash');
|
||||||
|
void displayEl.offsetWidth; // reflow
|
||||||
|
displayEl.textContent = newVal;
|
||||||
|
if (s.calledCount > 0) displayEl.classList.add('flash');
|
||||||
|
}
|
||||||
|
document.getElementById('display-sub').textContent = `대기 중 ${s.waitingCount}명`;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('state_update', applyState);
|
||||||
|
|
||||||
|
socket.on('ticket_issued', ({ ticketNumber, waitingCount }) => {
|
||||||
|
myTicketNumber = ticketNumber;
|
||||||
|
document.getElementById('issue-my-num').textContent = ticketNumber;
|
||||||
|
document.getElementById('issue-my-wait').textContent =
|
||||||
|
waitingCount > 0 ? `앞에 ${waitingCount - 1}명 대기 중` : '곧 호출됩니다!';
|
||||||
|
document.getElementById('issue-ticket-box').style.display = 'block';
|
||||||
|
document.getElementById('issue-btn').textContent = '새 번호표 뽑기';
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('reset_done', () => {
|
||||||
|
myTicketNumber = null;
|
||||||
|
document.getElementById('issue-ticket-box').style.display = 'none';
|
||||||
|
document.getElementById('issue-btn').disabled = false;
|
||||||
|
document.getElementById('issue-btn').textContent = '번호표 뽑기';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 재연결 시 상태 복구 ──────────────────────────
|
||||||
|
socket.on('connect', () => {
|
||||||
|
if (currentPin) socket.emit('join', { pin: currentPin });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
quadlet/queue.container
Normal file
25
quadlet/queue.container
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Queue Ticket App
|
||||||
|
After=podman.service network-online.target proxy-network.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=podman.service proxy-network.service
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
ContainerName=queue
|
||||||
|
Image=localhost/queue-app:latest
|
||||||
|
|
||||||
|
Network=proxy.network
|
||||||
|
IP=10.89.0.25
|
||||||
|
|
||||||
|
Environment=TZ=Asia/Seoul
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PORT=3000
|
||||||
|
|
||||||
|
Volume=/etc/localtime:/etc/localtime:ro
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
71
server.js
Normal file
71
server.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const io = new Server(server, {
|
||||||
|
pingTimeout: 60000,
|
||||||
|
pingInterval: 25000,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// pin -> { issuedCount, calledCount }
|
||||||
|
const sessions = {};
|
||||||
|
|
||||||
|
function sessionState(pin) {
|
||||||
|
const s = sessions[pin] || { issuedCount: 0, calledCount: 0 };
|
||||||
|
return {
|
||||||
|
issuedCount: s.issuedCount,
|
||||||
|
calledCount: s.calledCount,
|
||||||
|
waitingCount: s.issuedCount - s.calledCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
let pin = null;
|
||||||
|
|
||||||
|
socket.on('join', ({ pin: p }) => {
|
||||||
|
if (!p || typeof p !== 'string' || p.length < 4) {
|
||||||
|
socket.emit('join_error', 'PIN은 최소 4자리 이상이어야 합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pin = p.trim();
|
||||||
|
if (!sessions[pin]) {
|
||||||
|
sessions[pin] = { issuedCount: 0, calledCount: 0 };
|
||||||
|
}
|
||||||
|
socket.join(pin);
|
||||||
|
socket.emit('join_ok', sessionState(pin));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('issue_ticket', () => {
|
||||||
|
if (!pin || !sessions[pin]) return;
|
||||||
|
sessions[pin].issuedCount += 1;
|
||||||
|
const ticketNumber = sessions[pin].issuedCount;
|
||||||
|
const state = sessionState(pin);
|
||||||
|
socket.emit('ticket_issued', { ticketNumber, waitingCount: state.waitingCount });
|
||||||
|
io.to(pin).emit('state_update', state);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('call_next', () => {
|
||||||
|
if (!pin || !sessions[pin]) return;
|
||||||
|
const s = sessions[pin];
|
||||||
|
if (s.calledCount >= s.issuedCount) return;
|
||||||
|
s.calledCount += 1;
|
||||||
|
io.to(pin).emit('state_update', sessionState(pin));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('reset', () => {
|
||||||
|
if (!pin || !sessions[pin]) return;
|
||||||
|
sessions[pin] = { issuedCount: 0, calledCount: 0 };
|
||||||
|
io.to(pin).emit('state_update', sessionState(pin));
|
||||||
|
io.to(pin).emit('reset_done');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Queue server running on port ${PORT}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user