commit 13b09ef2ae4fddbe4c32c84ba712489a7c16b8ad Author: hyungi Date: Mon Oct 20 13:31:39 2025 +0900 ๐Ÿš€ ์ดˆ๊ธฐ ํ”„๋กœ์ ํŠธ ์„ค์ • ์™„๋ฃŒ โœจ ๊ธฐ๋Šฅ: - ๊ธฐ๊ฐ„์ œ ๊ทผ๋กœ์ž ์ž‘์—…๊ด€๋ฆฌ ์‹œ์Šคํ…œ ๊ธฐ๋ณธ ๊ตฌ์กฐ - ํ•œ๊ตญ์–ด ๊ธฐ๋ฐ˜ ํ”„๋ก ํŠธ์—”๋“œ (๋กœ๊ทธ์ธ, ๋Œ€์‹œ๋ณด๋“œ, ์ž‘์—…์ž ๊ด€๋ฆฌ) - Node.js Express ๋ฐฑ์—”๋“œ API ์„œ๋ฒ„ ๊ตฌ์กฐ - MySQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ ์„ค๊ณ„ - 14000๋ฒˆ๋Œ€ ํฌํŠธ ๊ตฌ์„ฑ์œผ๋กœ ์ถฉ๋Œ ๋ฐฉ์ง€ ๐Ÿ“ ๊ตฌ์กฐ: - frontend/ : HTML, CSS, JS (Bootstrap 5) - backend/ : Node.js, Express, MySQL - database/ : ์ดˆ๊ธฐํ™” ์Šคํฌ๋ฆฝํŠธ - docs/ : ๋ฌธ์„œ ๐Ÿ”Œ ํฌํŠธ: - ์›น: 14000, API: 14001, DB: 14002, phpMyAdmin: 14003 ๐ŸŽฏ ๋‹ค์Œ ๋‹จ๊ณ„: ๋ฐฑ์—”๋“œ API ๋ผ์šฐํŠธ ๊ตฌํ˜„ ๋ฐ Docker ์„ค์ • diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3eb437c --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +config.env +*.env + +# Logs +logs/ +*.log + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output/ + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Docker +.dockerignore + +# Database +*.sql.backup +*.db +*.sqlite +*.sqlite3 + +# Uploads +uploads/ +temp/ + +# Build +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a58448 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# ๊ธฐ๊ฐ„์ œ ๊ทผ๋กœ์ž ์ž‘์—…๊ด€๋ฆฌ ์‹œ์Šคํ…œ + +## ๐Ÿ“‹ ํ”„๋กœ์ ํŠธ ๊ฐœ์š” +๊ธฐ๊ฐ„์ œ ๊ทผ๋กœ์ž์˜ ์ผ์ผ ์ž‘์—…, ์—๋Ÿฌ์‚ฌํ•ญ, ์š”์ฒญ์‚ฌํ•ญ์„ ํšจ์œจ์ ์œผ๋กœ ๊ด€๋ฆฌํ•˜๋Š” ์›น ๊ธฐ๋ฐ˜ ์‹œ์Šคํ…œ + +## ๐ŸŽฏ ์ฃผ์š” ๊ธฐ๋Šฅ +- **์ธ์ฆ ์‹œ์Šคํ…œ**: Admin/User ๊ถŒํ•œ ๋ถ„๋ฆฌ +- **์ž‘์—…์ž ๊ด€๋ฆฌ**: ์šฉ์ ‘์‚ฌ/๋ฐฐ๊ด€์‚ฌ ์ง์ข…๋ณ„ ๊ด€๋ฆฌ +- **์ผ์ผ ์ž‘์—…๊ด€๋ฆฌ**: ๋‹น์ผ ์ž‘์—… ๊ธฐ๋ก ๋ฐ ์กฐํšŒ +- **์—๋Ÿฌ์‚ฌํ•ญ ๊ด€๋ฆฌ**: ๊ฐ„ํŽธํ•œ ์—๋Ÿฌ ๋“ฑ๋ก ๋ฐ ์ถ”์  +- **์š”์ฒญ์‚ฌํ•ญ ๊ด€๋ฆฌ**: ์žฅ๋น„/์†Œ๋ชจํ’ˆ ์š”์ฒญ ๊ด€๋ฆฌ + +## ๐Ÿ› ๏ธ ๊ธฐ์ˆ  ์Šคํƒ +- **Backend**: Node.js + Express + MySQL +- **Frontend**: Vanilla JS + Bootstrap 5 +- **Database**: MySQL 8.0 +- **๋ฐฐํฌ**: Docker + Docker Compose + +## ๐Ÿ”Œ ํฌํŠธ ๊ตฌ์„ฑ (14000๋ฒˆ๋Œ€) +| ์„œ๋น„์Šค | ํฌํŠธ | ์ ‘์† URL | ์šฉ๋„ | +|--------|------|----------|------| +| **์›น ์ธํ„ฐํŽ˜์ด์Šค** | `14000` | http://localhost:14000 | ํ”„๋ก ํŠธ์—”๋“œ (HTML/CSS/JS) | +| **API ์„œ๋ฒ„** | `14001` | http://localhost:14001/api | ๋ฐฑ์—”๋“œ REST API | +| **MySQL DB** | `14002` | localhost:14002 | ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ | +| **phpMyAdmin** | `14003` | http://localhost:14003 | DB ๊ด€๋ฆฌ ์›น ๋„๊ตฌ | + +> **๐Ÿ’ก ํฌํŠธ ์„ ํƒ ์ด์œ **: 14000๋ฒˆ๋Œ€๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋‹ค๋ฅธ ์„œ๋น„์Šค์™€์˜ ํฌํŠธ ์ถฉ๋Œ์„ ๋ฐฉ์ง€ + +## ๐Ÿ“‚ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ +``` +Worker-Management-System/ +โ”œโ”€โ”€ backend/ # Node.js API ์„œ๋ฒ„ +โ”œโ”€โ”€ frontend/ # ์›น ํด๋ผ์ด์–ธํŠธ +โ”œโ”€โ”€ database/ # DB ์ดˆ๊ธฐํ™” ์Šคํฌ๋ฆฝํŠธ +โ”œโ”€โ”€ docs/ # ๋ฌธ์„œ +โ”œโ”€โ”€ docker-compose.yml # Docker ์„ค์ • +โ””โ”€โ”€ README.md +``` + +## ๐Ÿš€ ์‹คํ–‰ ๋ฐฉ๋ฒ• +```bash +# ํ”„๋กœ์ ํŠธ ์‹คํ–‰ +docker-compose up -d + +# ๋กœ๊ทธ ํ™•์ธ +docker-compose logs -f + +# ์ข…๋ฃŒ +docker-compose down +``` + +## ๐Ÿ”ง ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์‹คํ–‰ +```bash +# ๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ ์„œ๋ฒ„ (ํฌํŠธ 14001) +cd backend +npm install +npm run dev + +# ํ”„๋ก ํŠธ์—”๋“œ (ํฌํŠธ 14000) +# ์›น ์„œ๋ฒ„ ๋˜๋Š” Live Server๋กœ frontend ํด๋” ์‹คํ–‰ +``` + +## ๐Ÿ“Š ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ +- `users`: ์‚ฌ์šฉ์ž ๊ณ„์ • (admin/user) +- `workers`: ์ž‘์—…์ž ์ •๋ณด (์šฉ์ ‘์‚ฌ/๋ฐฐ๊ด€์‚ฌ) +- `daily_work`: ์ผ์ผ ์ž‘์—… ๊ธฐ๋ก +- `error_reports`: ์—๋Ÿฌ์‚ฌํ•ญ ๊ธฐ๋ก +- `requests`: ์š”์ฒญ์‚ฌํ•ญ ๊ธฐ๋ก + +## ๐Ÿ” ๊ธฐ๋ณธ ๊ณ„์ • +- **Admin**: admin / admin123 +- **User**: user / user123 + +## ๐Ÿ“ฑ ์„œ๋น„์Šค ์ ‘์† ๋ฐฉ๋ฒ• + +### ๐ŸŒ ์›น ๋ธŒ๋ผ์šฐ์ € ์ ‘์† +- **๋ฉ”์ธ ์›น์‚ฌ์ดํŠธ**: http://localhost:14000 +- **API ๋ฌธ์„œ**: http://localhost:14001 (์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ) +- **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ด€๋ฆฌ**: http://localhost:14003 (phpMyAdmin) + +### ๐Ÿ”— API ์—”๋“œํฌ์ธํŠธ +- **๊ธฐ๋ณธ URL**: http://localhost:14001/api +- **๋กœ๊ทธ์ธ**: POST /api/auth/login +- **์ž‘์—…์ž ๋ชฉ๋ก**: GET /api/workers +- **์ผ์ผ ์ž‘์—…**: GET /api/daily-work +- **์—๋Ÿฌ์‚ฌํ•ญ**: GET /api/errors +- **์š”์ฒญ์‚ฌํ•ญ**: GET /api/requests + +### ๐Ÿ—„๏ธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์ •๋ณด +- **ํ˜ธ์ŠคํŠธ**: localhost +- **ํฌํŠธ**: 14002 +- **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ช…**: worker_management +- **์‚ฌ์šฉ์ž๋ช…**: root +- **๋น„๋ฐ€๋ฒˆํ˜ธ**: rootpassword diff --git a/backend/models/database.js b/backend/models/database.js new file mode 100644 index 0000000..fce6817 --- /dev/null +++ b/backend/models/database.js @@ -0,0 +1,101 @@ +const mysql = require('mysql2/promise'); + +// ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ํ’€ ์ƒ์„ฑ +const pool = mysql.createPool({ + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 3306, + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || 'rootpassword', + database: process.env.DB_NAME || 'worker_management', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + acquireTimeout: 60000, + timeout: 60000, + reconnect: true, + charset: 'utf8mb4' +}); + +// ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ +async function testConnection() { + try { + const connection = await pool.getConnection(); + console.log('โœ… ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์„ฑ๊ณต'); + connection.release(); + return true; + } catch (error) { + console.error('โŒ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์‹คํŒจ:', error.message); + return false; + } +} + +// ์ฟผ๋ฆฌ ์‹คํ–‰ ํ•จ์ˆ˜ +async function executeQuery(sql, params = []) { + try { + const [rows] = await pool.execute(sql, params); + return rows; + } catch (error) { + console.error('์ฟผ๋ฆฌ ์‹คํ–‰ ์˜ค๋ฅ˜:', error); + throw error; + } +} + +// ํŠธ๋žœ์žญ์…˜ ์‹คํ–‰ ํ•จ์ˆ˜ +async function executeTransaction(queries) { + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + const results = []; + for (const { sql, params } of queries) { + const [result] = await connection.execute(sql, params); + results.push(result); + } + + await connection.commit(); + return results; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} + +// ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ฟผ๋ฆฌ ํ•จ์ˆ˜ +async function executePagedQuery(sql, params = [], page = 1, limit = 10) { + const offset = (page - 1) * limit; + + // ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ + const countSql = `SELECT COUNT(*) as total FROM (${sql}) as count_query`; + const [countResult] = await pool.execute(countSql, params); + const total = countResult[0].total; + + // ํŽ˜์ด์ง€ ๋ฐ์ดํ„ฐ ์กฐํšŒ + const pagedSql = `${sql} LIMIT ? OFFSET ?`; + const [rows] = await pool.execute(pagedSql, [...params, limit, offset]); + + return { + data: rows, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + hasNext: page < Math.ceil(total / limit), + hasPrev: page > 1 + } + }; +} + +// ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™” ์‹œ ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ +testConnection(); + +module.exports = { + pool, + executeQuery, + executeTransaction, + executePagedQuery, + testConnection +}; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c97cbe2 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,37 @@ +{ + "name": "worker-management-backend", + "version": "1.0.0", + "description": "๊ธฐ๊ฐ„์ œ ๊ทผ๋กœ์ž ์ž‘์—…๊ด€๋ฆฌ ์‹œ์Šคํ…œ ๋ฐฑ์—”๋“œ API", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "worker", + "management", + "api", + "express", + "mysql" + ], + "author": "Hyungi", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "mysql2": "^3.6.0", + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "helmet": "^7.0.0", + "express-rate-limit": "^6.10.0", + "joi": "^17.9.2" + }, + "devDependencies": { + "nodemon": "^3.0.1" + }, + "engines": { + "node": ">=16.0.0" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..1fdd25c --- /dev/null +++ b/backend/server.js @@ -0,0 +1,102 @@ +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +require('dotenv').config({ path: './config.env' }); + +const authRoutes = require('./routes/auth'); +const workerRoutes = require('./routes/workers'); +const dailyWorkRoutes = require('./routes/dailyWork'); +const errorRoutes = require('./routes/errors'); +const requestRoutes = require('./routes/requests'); +const dashboardRoutes = require('./routes/dashboard'); + +const app = express(); +const PORT = process.env.PORT || 14001; + +// ๋ณด์•ˆ ๋ฏธ๋“ค์›จ์–ด +app.use(helmet()); + +// Rate limiting +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15๋ถ„ + max: 100, // ์ตœ๋Œ€ 100๊ฐœ ์š”์ฒญ + message: { + error: '๋„ˆ๋ฌด ๋งŽ์€ ์š”์ฒญ์ด ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.' + } +}); +app.use('/api/', limiter); + +// CORS ์„ค์ • +app.use(cors({ + origin: process.env.CORS_ORIGIN || 'http://localhost:3000', + credentials: true +})); + +// Body parser +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// ๋กœ๊น… ๋ฏธ๋“ค์›จ์–ด +app.use((req, res, next) => { + console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); + next(); +}); + +// API ๋ผ์šฐํŠธ +app.use('/api/auth', authRoutes); +app.use('/api/workers', workerRoutes); +app.use('/api/daily-work', dailyWorkRoutes); +app.use('/api/errors', errorRoutes); +app.use('/api/requests', requestRoutes); +app.use('/api/dashboard', dashboardRoutes); + +// ๊ธฐ๋ณธ ๋ผ์šฐํŠธ +app.get('/', (req, res) => { + res.json({ + message: '๊ธฐ๊ฐ„์ œ ๊ทผ๋กœ์ž ์ž‘์—…๊ด€๋ฆฌ ์‹œ์Šคํ…œ API', + version: '1.0.0', + status: 'running', + timestamp: new Date().toISOString() + }); +}); + +// 404 ์—๋Ÿฌ ์ฒ˜๋ฆฌ +app.use((req, res) => { + res.status(404).json({ + error: '์š”์ฒญํ•œ ๋ฆฌ์†Œ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + path: req.path, + method: req.method + }); +}); + +// ์ „์—ญ ์—๋Ÿฌ ์ฒ˜๋ฆฌ +app.use((err, req, res, next) => { + console.error('์„œ๋ฒ„ ์˜ค๋ฅ˜:', err); + + res.status(err.status || 500).json({ + error: process.env.NODE_ENV === 'production' + ? '์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.' + : err.message, + ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }) + }); +}); + +// ์„œ๋ฒ„ ์‹œ์ž‘ +app.listen(PORT, () => { + console.log(`๐Ÿš€ ์„œ๋ฒ„๊ฐ€ ํฌํŠธ ${PORT}์—์„œ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค.`); + console.log(`๐Ÿ“Š ๋Œ€์‹œ๋ณด๋“œ: http://localhost:${PORT}`); + console.log(`๐Ÿ”ง ํ™˜๊ฒฝ: ${process.env.NODE_ENV}`); + console.log(`๐Ÿ—„๏ธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค: ${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`); +}); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('๐Ÿ›‘ ์„œ๋ฒ„ ์ข…๋ฃŒ ์‹ ํ˜ธ๋ฅผ ๋ฐ›์•˜์Šต๋‹ˆ๋‹ค.'); + process.exit(0); +}); + +process.on('SIGINT', () => { + console.log('๐Ÿ›‘ ์„œ๋ฒ„๋ฅผ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค.'); + process.exit(0); +}); diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..bae4102 --- /dev/null +++ b/database/init.sql @@ -0,0 +1,124 @@ +-- ๊ธฐ๊ฐ„์ œ ๊ทผ๋กœ์ž ์ž‘์—…๊ด€๋ฆฌ ์‹œ์Šคํ…œ DB ์ดˆ๊ธฐํ™” ์Šคํฌ๋ฆฝํŠธ + +CREATE DATABASE IF NOT EXISTS worker_management; +USE worker_management; + +-- ์‚ฌ์šฉ์ž ํ…Œ์ด๋ธ” (admin/user ๊ถŒํ•œ) +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + role ENUM('admin', 'user') DEFAULT 'user', + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- ์ž‘์—…์ž ํ…Œ์ด๋ธ” (์šฉ์ ‘์‚ฌ/๋ฐฐ๊ด€์‚ฌ) +CREATE TABLE workers ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + job_type ENUM('welder', 'plumber') NOT NULL COMMENT '์šฉ์ ‘์‚ฌ/๋ฐฐ๊ด€์‚ฌ', + phone VARCHAR(20), + hire_date DATE, + status ENUM('active', 'inactive') DEFAULT 'active', + created_by INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES users(id) +); + +-- ์ผ์ผ ์ž‘์—… ๊ธฐ๋ก ํ…Œ์ด๋ธ” +CREATE TABLE daily_work ( + id INT AUTO_INCREMENT PRIMARY KEY, + worker_id INT NOT NULL, + work_date DATE NOT NULL, + work_description TEXT, + start_time TIME, + end_time TIME, + work_hours DECIMAL(4,2) COMMENT '์ž‘์—… ์‹œ๊ฐ„', + location VARCHAR(200) COMMENT '์ž‘์—… ์œ„์น˜', + status ENUM('planned', 'in_progress', 'completed', 'cancelled') DEFAULT 'planned', + notes TEXT COMMENT 'ํŠน์ด์‚ฌํ•ญ', + created_by INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (worker_id) REFERENCES workers(id), + FOREIGN KEY (created_by) REFERENCES users(id), + INDEX idx_work_date (work_date), + INDEX idx_worker_date (worker_id, work_date) +); + +-- ์—๋Ÿฌ์‚ฌํ•ญ ๊ธฐ๋ก ํ…Œ์ด๋ธ” +CREATE TABLE error_reports ( + id INT AUTO_INCREMENT PRIMARY KEY, + worker_id INT, + error_date DATE NOT NULL, + error_time TIME, + error_type ENUM('equipment', 'safety', 'quality', 'process', 'other') NOT NULL, + error_description TEXT NOT NULL, + location VARCHAR(200), + severity ENUM('low', 'medium', 'high', 'critical') DEFAULT 'medium', + status ENUM('reported', 'investigating', 'resolved', 'closed') DEFAULT 'reported', + resolution TEXT COMMENT 'ํ•ด๊ฒฐ ๋ฐฉ์•ˆ', + resolved_at TIMESTAMP NULL, + resolved_by INT, + created_by INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (worker_id) REFERENCES workers(id), + FOREIGN KEY (resolved_by) REFERENCES users(id), + FOREIGN KEY (created_by) REFERENCES users(id), + INDEX idx_error_date (error_date), + INDEX idx_status (status) +); + +-- ์š”์ฒญ์‚ฌํ•ญ ํ…Œ์ด๋ธ” (์žฅ๋น„/์†Œ๋ชจํ’ˆ) +CREATE TABLE requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + worker_id INT, + request_type ENUM('equipment', 'supplies', 'maintenance', 'other') NOT NULL, + item_name VARCHAR(200) NOT NULL, + quantity INT DEFAULT 1, + description TEXT, + urgency ENUM('low', 'normal', 'high', 'urgent') DEFAULT 'normal', + status ENUM('pending', 'approved', 'ordered', 'delivered', 'rejected') DEFAULT 'pending', + requested_date DATE NOT NULL, + needed_date DATE COMMENT 'ํ•„์š” ๋‚ ์งœ', + approved_by INT, + approved_at TIMESTAMP NULL, + notes TEXT COMMENT '๊ด€๋ฆฌ์ž ๋ฉ”๋ชจ', + created_by INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (worker_id) REFERENCES workers(id), + FOREIGN KEY (approved_by) REFERENCES users(id), + FOREIGN KEY (created_by) REFERENCES users(id), + INDEX idx_request_date (requested_date), + INDEX idx_status (status) +); + +-- ๊ธฐ๋ณธ ๋ฐ์ดํ„ฐ ์‚ฝ์ž… +INSERT INTO users (username, password, role, name) VALUES +('admin', '$2b$10$rOzJqQjQjQjQjQjQjQjQjOzJqQjQjQjQjQjQjQjQjQjQjQjQjQjQjQ', 'admin', '๊ด€๋ฆฌ์ž'), +('user', '$2b$10$rOzJqQjQjQjQjQjQjQjQjOzJqQjQjQjQjQjQjQjQjQjQjQjQjQjQjQ', 'user', '์‚ฌ์šฉ์ž'); + +-- ์ƒ˜ํ”Œ ์ž‘์—…์ž ๋ฐ์ดํ„ฐ +INSERT INTO workers (name, job_type, phone, hire_date, created_by) VALUES +('๊น€์šฉ์ ‘', 'welder', '010-1234-5678', '2024-01-15', 1), +('์ด๋ฐฐ๊ด€', 'plumber', '010-2345-6789', '2024-02-01', 1), +('๋ฐ•์šฉ์ ‘', 'welder', '010-3456-7890', '2024-03-01', 1); + +-- ์ƒ˜ํ”Œ ์ผ์ผ ์ž‘์—… ๋ฐ์ดํ„ฐ +INSERT INTO daily_work (worker_id, work_date, work_description, start_time, end_time, work_hours, location, status, created_by) VALUES +(1, CURDATE(), 'ํŒŒ์ดํ”„ ์šฉ์ ‘ ์ž‘์—…', '09:00:00', '17:00:00', 8.0, '1์ธต ์ž‘์—…์žฅ', 'completed', 1), +(2, CURDATE(), '๋ฐฐ๊ด€ ์„ค์น˜ ์ž‘์—…', '09:00:00', '16:00:00', 7.0, '2์ธต ํ™”์žฅ์‹ค', 'in_progress', 1); + +-- ์ƒ˜ํ”Œ ์—๋Ÿฌ์‚ฌํ•ญ ๋ฐ์ดํ„ฐ +INSERT INTO error_reports (worker_id, error_date, error_time, error_type, error_description, location, severity, created_by) VALUES +(1, CURDATE(), '14:30:00', 'equipment', '์šฉ์ ‘๊ธฐ ๊ณผ์—ด๋กœ ์ธํ•œ ์ž‘์—… ์ค‘๋‹จ', '1์ธต ์ž‘์—…์žฅ', 'medium', 1); + +-- ์ƒ˜ํ”Œ ์š”์ฒญ์‚ฌํ•ญ ๋ฐ์ดํ„ฐ +INSERT INTO requests (worker_id, request_type, item_name, quantity, description, urgency, requested_date, needed_date, created_by) VALUES +(1, 'supplies', '์šฉ์ ‘๋ด‰', 10, 'STS ์šฉ์ ‘๋ด‰ ํ•„์š”', 'normal', CURDATE(), DATE_ADD(CURDATE(), INTERVAL 3 DAY), 1), +(2, 'equipment', 'ํŒŒ์ดํ”„ ์ปคํ„ฐ', 1, '๋Œ€ํ˜• ํŒŒ์ดํ”„ ์ ˆ๋‹จ์šฉ', 'high', CURDATE(), DATE_ADD(CURDATE(), INTERVAL 1 DAY), 1); diff --git a/frontend/css/dashboard.css b/frontend/css/dashboard.css new file mode 100644 index 0000000..950292d --- /dev/null +++ b/frontend/css/dashboard.css @@ -0,0 +1,292 @@ +/* ์ž‘์—…๊ด€๋ฆฌ ์‹œ์Šคํ…œ ๊ณตํ†ต ์Šคํƒ€์ผ */ + +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f8f9fa; +} + +/* ๋„ค๋น„๊ฒŒ์ด์…˜ ์Šคํƒ€์ผ */ +.navbar-brand { + font-weight: 700; + font-size: 1.5rem; +} + +.nav-link { + font-weight: 500; + transition: all 0.3s ease; +} + +.nav-link:hover { + transform: translateY(-1px); +} + +.nav-link.active { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 5px; +} + +/* ์นด๋“œ ์Šคํƒ€์ผ */ +.card { + border: none; + border-radius: 15px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08); + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); +} + +.card-header { + background-color: #fff; + border-bottom: 2px solid #f8f9fa; + border-radius: 15px 15px 0 0 !important; + font-weight: 600; +} + +/* ํ†ต๊ณ„ ์นด๋“œ */ +.card.bg-primary, +.card.bg-success, +.card.bg-warning, +.card.bg-info { + background: linear-gradient(135deg, var(--bs-primary) 0%, #764ba2 100%) !important; +} + +.card.bg-success { + background: linear-gradient(135deg, var(--bs-success) 0%, #56ab2f 100%) !important; +} + +.card.bg-warning { + background: linear-gradient(135deg, var(--bs-warning) 0%, #f093fb 100%) !important; +} + +.card.bg-info { + background: linear-gradient(135deg, var(--bs-info) 0%, #4facfe 100%) !important; +} + +/* ๋ฒ„ํŠผ ์Šคํƒ€์ผ */ +.btn { + border-radius: 10px; + font-weight: 500; + transition: all 0.3s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; +} + +.btn-success { + background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%); + border: none; +} + +.btn-warning { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + border: none; +} + +.btn-info { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); + border: none; +} + +/* ํ…Œ์ด๋ธ” ์Šคํƒ€์ผ */ +.table { + border-radius: 10px; + overflow: hidden; +} + +.table thead th { + background-color: #f8f9fa; + border: none; + font-weight: 600; + color: #495057; +} + +.table tbody tr { + transition: all 0.3s ease; +} + +.table tbody tr:hover { + background-color: #f8f9fa; + transform: scale(1.01); +} + +/* ํผ ์Šคํƒ€์ผ */ +.form-control, +.form-select { + border-radius: 10px; + border: 2px solid #e9ecef; + transition: all 0.3s ease; +} + +.form-control:focus, +.form-select:focus { + border-color: #667eea; + box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25); +} + +/* ๋ชจ๋‹ฌ ์Šคํƒ€์ผ */ +.modal-content { + border-radius: 15px; + border: none; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1); +} + +.modal-header { + border-bottom: 2px solid #f8f9fa; + border-radius: 15px 15px 0 0; +} + +.modal-footer { + border-top: 2px solid #f8f9fa; + border-radius: 0 0 15px 15px; +} + +/* ๋ฐฐ์ง€ ์Šคํƒ€์ผ */ +.badge { + border-radius: 20px; + font-weight: 500; + padding: 0.5em 1em; +} + +/* ์ง์ข…๋ณ„ ๋ฐฐ์ง€ ์ƒ‰์ƒ */ +.badge.job-welder { + background-color: #ff6b6b; +} + +.badge.job-plumber { + background-color: #4ecdc4; +} + +/* ์ƒํƒœ๋ณ„ ๋ฐฐ์ง€ ์ƒ‰์ƒ */ +.badge.status-active { + background-color: #51cf66; +} + +.badge.status-inactive { + background-color: #868e96; +} + +.badge.status-completed { + background-color: #51cf66; +} + +.badge.status-in-progress { + background-color: #339af0; +} + +.badge.status-planned { + background-color: #ffd43b; + color: #495057; +} + +.badge.status-cancelled { + background-color: #868e96; +} + +/* ์šฐ์„ ์ˆœ์œ„๋ณ„ ๋ฐฐ์ง€ ์ƒ‰์ƒ */ +.badge.priority-low { + background-color: #51cf66; +} + +.badge.priority-normal { + background-color: #339af0; +} + +.badge.priority-high { + background-color: #ff8cc8; +} + +.badge.priority-urgent { + background-color: #ff6b6b; +} + +/* ์‹ฌ๊ฐ๋„๋ณ„ ๋ฐฐ์ง€ ์ƒ‰์ƒ */ +.badge.severity-low { + background-color: #51cf66; +} + +.badge.severity-medium { + background-color: #ffd43b; + color: #495057; +} + +.badge.severity-high { + background-color: #ff8cc8; +} + +.badge.severity-critical { + background-color: #ff6b6b; +} + +/* ํ† ์ŠคํŠธ ์Šคํƒ€์ผ */ +.toast { + border-radius: 10px; + border: none; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); +} + +/* ๋ฐ˜์‘ํ˜• ์Šคํƒ€์ผ */ +@media (max-width: 768px) { + .container-fluid { + padding-left: 15px; + padding-right: 15px; + } + + .card-body { + padding: 1rem; + } + + .btn { + font-size: 0.9rem; + } + + .table-responsive { + font-size: 0.9rem; + } +} + +/* ๋กœ๋”ฉ ์• ๋‹ˆ๋ฉ”์ด์…˜ */ +@keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } +} + +.loading { + animation: pulse 1.5s ease-in-out infinite; +} + +/* ์Šคํฌ๋กค๋ฐ” ์Šคํƒ€์ผ */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} diff --git a/frontend/dashboard.html b/frontend/dashboard.html new file mode 100644 index 0000000..b459c63 --- /dev/null +++ b/frontend/dashboard.html @@ -0,0 +1,216 @@ + + + + + + ์ž‘์—…๊ด€๋ฆฌ ์‹œ์Šคํ…œ - ๋Œ€์‹œ๋ณด๋“œ + + + + + + + + +
+
+ +
+
+

๋Œ€์‹œ๋ณด๋“œ

+
+ +
+
+ + +
+
+
+
+
+
+

0

+

์ „์ฒด ์ž‘์—…์ž

+
+
+ +
+
+
+
+
+
+
+
+
+
+

0

+

์˜ค๋Š˜ ์ž‘์—…

+
+
+ +
+
+
+
+
+
+
+
+
+
+

0

+

๋ฏธํ•ด๊ฒฐ ์—๋Ÿฌ

+
+
+ +
+
+
+
+
+
+
+
+
+
+

0

+

๋Œ€๊ธฐ ์š”์ฒญ

+
+
+ +
+
+
+
+
+
+ + +
+
+
+
+
์ตœ๊ทผ ์ž‘์—…
+
+
+
+
+ +

๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+
+
+
+
+
+
+
+
์ตœ๊ทผ ์—๋Ÿฌ
+
+
+
+
+ +

๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+
+
+
+
+
+ + +
+
+
+
+
๋น ๋ฅธ ์ž‘์—…
+
+
+
+ +
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c6cbd4d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,140 @@ + + + + + + ์ž‘์—…๊ด€๋ฆฌ ์‹œ์Šคํ…œ - ๋กœ๊ทธ์ธ + + + + + +
+
+
+ +
+
+
+ + + + + + + + diff --git a/frontend/js/auth.js b/frontend/js/auth.js new file mode 100644 index 0000000..83585ce --- /dev/null +++ b/frontend/js/auth.js @@ -0,0 +1,266 @@ +// ์ธ์ฆ ๊ด€๋ จ JavaScript ํ•จ์ˆ˜๋“ค + +const API_BASE_URL = 'http://localhost:14001/api'; + +// ๋กœ๊ทธ์ธ ํ•จ์ˆ˜ +async function login(username, password) { + try { + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }) + }); + + const data = await response.json(); + + if (response.ok) { + // ํ† ํฐ๊ณผ ์‚ฌ์šฉ์ž ์ •๋ณด ์ €์žฅ + localStorage.setItem('token', data.token); + localStorage.setItem('user', JSON.stringify(data.user)); + + // ๋Œ€์‹œ๋ณด๋“œ๋กœ ์ด๋™ + window.location.href = 'dashboard.html'; + } else { + throw new Error(data.message || '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + } catch (error) { + console.error('๋กœ๊ทธ์ธ ์˜ค๋ฅ˜:', error); + showAlert('๋กœ๊ทธ์ธ ์‹คํŒจ', error.message); + } +} + +// ๋กœ๊ทธ์•„์›ƒ ํ•จ์ˆ˜ +function logout() { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = 'index.html'; +} + +// ํ† ํฐ ํ™•์ธ ํ•จ์ˆ˜ +function checkAuth() { + const token = localStorage.getItem('token'); + const user = localStorage.getItem('user'); + + if (!token || !user) { + // ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ์—๋งŒ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + if (!window.location.pathname.includes('index.html') && window.location.pathname !== '/') { + window.location.href = 'index.html'; + } + return null; + } + + try { + return JSON.parse(user); + } catch (error) { + console.error('์‚ฌ์šฉ์ž ์ •๋ณด ํŒŒ์‹ฑ ์˜ค๋ฅ˜:', error); + logout(); + return null; + } +} + +// ๊ด€๋ฆฌ์ž ๊ถŒํ•œ ํ™•์ธ +function isAdmin() { + const user = checkAuth(); + return user && user.role === 'admin'; +} + +// API ์š”์ฒญ ํ—ค๋”์— ํ† ํฐ ์ถ”๊ฐ€ +function getAuthHeaders() { + const token = localStorage.getItem('token'); + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }; +} + +// API ์š”์ฒญ ํ•จ์ˆ˜ +async function apiRequest(url, options = {}) { + const defaultOptions = { + headers: getAuthHeaders() + }; + + const mergedOptions = { + ...defaultOptions, + ...options, + headers: { + ...defaultOptions.headers, + ...options.headers + } + }; + + try { + const response = await fetch(`${API_BASE_URL}${url}`, mergedOptions); + + // ์ธ์ฆ ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ + if (response.status === 401) { + logout(); + return; + } + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || '์š”์ฒญ ์ฒ˜๋ฆฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + return data; + } catch (error) { + console.error('API ์š”์ฒญ ์˜ค๋ฅ˜:', error); + throw error; + } +} + +// ์•Œ๋ฆผ ํ‘œ์‹œ ํ•จ์ˆ˜ +function showAlert(title, message, type = 'danger') { + const alertModal = document.getElementById('alertModal'); + if (alertModal) { + document.getElementById('alertMessage').innerHTML = `${title}
${message}`; + const modal = new bootstrap.Modal(alertModal); + modal.show(); + } else { + alert(`${title}: ${message}`); + } +} + +// ํ† ์ŠคํŠธ ์•Œ๋ฆผ ํ‘œ์‹œ ํ•จ์ˆ˜ +function showToast(message, type = 'success') { + const toastElement = document.getElementById('alertToast'); + const toastMessage = document.getElementById('toastMessage'); + + if (toastElement && toastMessage) { + toastMessage.textContent = message; + + // ํ† ์ŠคํŠธ ์ƒ‰์ƒ ์„ค์ • + toastElement.className = `toast ${type === 'success' ? 'bg-success' : 'bg-danger'} text-white`; + + const toast = new bootstrap.Toast(toastElement); + toast.show(); + } +} + +// ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ธ์ฆ ํ™•์ธ +document.addEventListener('DOMContentLoaded', function() { + // ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ ์ธ์ฆ ํ™•์ธ + if (!window.location.pathname.includes('index.html') && window.location.pathname !== '/') { + const user = checkAuth(); + if (user) { + // ์‚ฌ์šฉ์ž ์ด๋ฆ„ ํ‘œ์‹œ + const userNameDisplay = document.getElementById('userNameDisplay'); + if (userNameDisplay) { + userNameDisplay.textContent = user.name; + } + + // ๊ด€๋ฆฌ์ž๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ ์ž‘์—…์ž ๊ด€๋ฆฌ ๋ฉ”๋‰ด ์ˆจ๊ธฐ๊ธฐ + if (!isAdmin()) { + const workerManagementNav = document.getElementById('workerManagementNav'); + const addWorkerBtn = document.getElementById('addWorkerBtn'); + + if (workerManagementNav) workerManagementNav.style.display = 'none'; + if (addWorkerBtn) addWorkerBtn.style.display = 'none'; + } + } + } + + // ๋กœ๊ทธ์ธ ํผ ์ฒ˜๋ฆฌ + const loginForm = document.getElementById('loginForm'); + if (loginForm) { + loginForm.addEventListener('submit', async function(e) { + e.preventDefault(); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + if (!username || !password) { + showAlert('์ž…๋ ฅ ์˜ค๋ฅ˜', '์•„์ด๋””์™€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ชจ๋‘ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); + return; + } + + await login(username, password); + }); + } +}); + +// ๋‚ ์งœ ํฌ๋งท ํ•จ์ˆ˜ +function formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); +} + +// ์‹œ๊ฐ„ ํฌ๋งท ํ•จ์ˆ˜ +function formatTime(timeString) { + if (!timeString) return ''; + return timeString.substring(0, 5); // HH:MM ํ˜•์‹์œผ๋กœ ์ž๋ฅด๊ธฐ +} + +// ์ง์ข… ํ•œ๊ธ€ ๋ณ€ํ™˜ +function getJobTypeText(jobType) { + const jobTypes = { + 'welder': '์šฉ์ ‘์‚ฌ', + 'plumber': '๋ฐฐ๊ด€์‚ฌ' + }; + return jobTypes[jobType] || jobType; +} + +// ์ƒํƒœ ํ•œ๊ธ€ ๋ณ€ํ™˜ +function getStatusText(status, type = 'general') { + const statusTexts = { + general: { + 'active': 'ํ™œ์„ฑ', + 'inactive': '๋น„ํ™œ์„ฑ', + 'pending': '๋Œ€๊ธฐ', + 'approved': '์Šน์ธ', + 'rejected': '๊ฑฐ๋ถ€', + 'completed': '์™„๋ฃŒ', + 'cancelled': '์ทจ์†Œ' + }, + work: { + 'planned': '๊ณ„ํš', + 'in_progress': '์ง„ํ–‰์ค‘', + 'completed': '์™„๋ฃŒ', + 'cancelled': '์ทจ์†Œ' + }, + error: { + 'reported': '์‹ ๊ณ ๋จ', + 'investigating': '์กฐ์‚ฌ์ค‘', + 'resolved': 'ํ•ด๊ฒฐ๋จ', + 'closed': '์ข…๋ฃŒ' + }, + request: { + 'pending': '๋Œ€๊ธฐ', + 'approved': '์Šน์ธ', + 'ordered': '์ฃผ๋ฌธ', + 'delivered': '๋ฐฐ์†ก์™„๋ฃŒ', + 'rejected': '๊ฑฐ๋ถ€' + } + }; + + return statusTexts[type][status] || status; +} + +// ์šฐ์„ ์ˆœ์œ„ ํ•œ๊ธ€ ๋ณ€ํ™˜ +function getPriorityText(priority) { + const priorities = { + 'low': '๋‚ฎ์Œ', + 'normal': '๋ณดํ†ต', + 'high': '๋†’์Œ', + 'urgent': '๊ธด๊ธ‰' + }; + return priorities[priority] || priority; +} + +// ์‹ฌ๊ฐ๋„ ํ•œ๊ธ€ ๋ณ€ํ™˜ +function getSeverityText(severity) { + const severities = { + 'low': '๋‚ฎ์Œ', + 'medium': '๋ณดํ†ต', + 'high': '๋†’์Œ', + 'critical': '์‹ฌ๊ฐ' + }; + return severities[severity] || severity; +} diff --git a/frontend/workers.html b/frontend/workers.html new file mode 100644 index 0000000..7f0737c --- /dev/null +++ b/frontend/workers.html @@ -0,0 +1,224 @@ + + + + + + ์ž‘์—…๊ด€๋ฆฌ ์‹œ์Šคํ…œ - ์ž‘์—…์ž ๊ด€๋ฆฌ + + + + + + + + +
+
+
+
+

์ž‘์—…์ž ๊ด€๋ฆฌ

+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
์ž‘์—…์ž ๋ชฉ๋ก
+
+
+
+ + + + + + + + + + + + + + + + + +
์ด๋ฆ„์ง์ข…์—ฐ๋ฝ์ฒ˜์ž…์‚ฌ์ผ์ƒํƒœ๋“ฑ๋ก์ผ์ž‘์—…
+
+ ๋กœ๋”ฉ ์ค‘... +
+

์ž‘์—…์ž ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+
+
+
+
+
+
+ + + + + + + + +
+ +
+ + + + + +