commit dbb35d35c42bf6399f665c8ee5cb7a4bddad83c6 Author: 박성필 Date: Mon Aug 10 21:19:10 2026 +0900 feat: GTSIT 사이트 구축 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ab285 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +.secrets/ +docs/*.pem diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ff53202 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# GTSIT 프로젝트 작업 규칙 + +## 기본 원칙 + +- 사용자에게 보여주는 설명과 진행 상황은 한국어로 작성한다. +- 작업 시작 시 `docs/README.md`, `docs/architecture.md`, `docs/implementation-todo.md`, `docs/qa-list.md`에서 현재 상태와 다음 단계를 확인한다. +- 코드, 배포, 호스팅, 도메인, DB 설정을 진행하면서 관련 문서를 백그라운드에서 함께 갱신한다. +- 문서 갱신을 별도 후속 작업으로 미루지 않고 해당 변경의 완료 조건에 포함한다. + +## 문서별 갱신 기준 + +- `docs/README.md`: 문서 구성, 현재 목표, 운영 원칙이 바뀔 때 갱신 +- `docs/architecture.md`: 페이지 구조, 기술 구성, 데이터 모델, 호스팅 사양, 보안 또는 배포 전략이 바뀔 때 갱신 +- `docs/implementation-todo.md`: 작업 시작·완료·차단 상태와 사용자/Codex 담당이 바뀔 때 즉시 갱신 +- `docs/qa-list.md`: 기능 추가나 결함 발견 시 검증 항목을 추가하고, 실제 검증을 통과한 항목만 완료 처리 + +## 상태 기록 규칙 + +- 실제로 구현하거나 검증한 항목만 `[x]`로 표시한다. +- 진행하지 않은 항목, 사용자 확인이 필요한 항목, 재검증이 필요한 항목은 `[ ]`로 유지한다. +- 외부 설정이 실패하거나 차단되면 원인, 현재 영향, 다음 행동을 관련 문서에 기록한다. +- 중요한 설계 결정과 운영 환경 변경에는 확인 날짜를 기록한다. +- 배포 URL, 환경, 기기 등 QA 재현에 필요한 정보는 `docs/qa-list.md` 실행 기록에 남긴다. +- 작업 종료 전 코드 변경과 문서 상태가 서로 일치하는지 확인한다. + +## 보안 + +- 비밀번호, DB 접속 비밀번호, SSH 개인키, 인증번호, 세션 정보는 문서와 Git에 기록하지 않는다. +- 인증키와 로컬 비밀정보는 `.secrets/`에 보관하고 파일 권한을 제한한다. +- 문서에는 민감정보 자체가 아닌 설정 상태, 확인 결과, 재현 가능한 안전한 절차만 기록한다. diff --git a/app/Auth/AdminAuth.php b/app/Auth/AdminAuth.php new file mode 100644 index 0000000..6055a4c --- /dev/null +++ b/app/Auth/AdminAuth.php @@ -0,0 +1,81 @@ +db = Database::connection(); + } + + public function attempt(string $username, string $password): bool + { + $statement = $this->db->prepare( + 'SELECT id, username, display_name, password_hash, is_active, failed_attempts, locked_until + FROM admins WHERE username = :username LIMIT 1' + ); + $statement->execute(['username' => $username]); + $admin = $statement->fetch(); + + if (!$admin) { + password_verify($password, password_hash('invalid-login', PASSWORD_DEFAULT)); + return false; + } + + if (!(bool) $admin['is_active']) { + return false; + } + + if ($admin['locked_until'] && strtotime($admin['locked_until']) > time()) { + return false; + } + + if (!password_verify($password, $admin['password_hash'])) { + $failed = (int) $admin['failed_attempts'] + 1; + $lockedUntil = $failed >= 5 ? date('Y-m-d H:i:s', time() + 900) : null; + $update = $this->db->prepare('UPDATE admins SET failed_attempts = :failed, locked_until = :locked WHERE id = :id'); + $update->execute(['failed' => $failed >= 5 ? 0 : $failed, 'locked' => $lockedUntil, 'id' => $admin['id']]); + return false; + } + + $update = $this->db->prepare('UPDATE admins SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = :id'); + $update->execute(['id' => $admin['id']]); + session_regenerate_id(true); + $_SESSION['admin'] = [ + 'id' => (int) $admin['id'], + 'username' => $admin['username'], + 'display_name' => $admin['display_name'], + 'authenticated_at' => time(), + ]; + return true; + } + + public static function check(): bool + { + return isset($_SESSION['admin']['id'], $_SESSION['admin']['authenticated_at']) + && time() - (int) $_SESSION['admin']['authenticated_at'] < 7200; + } + + public static function requireLogin(): void + { + if (!self::check()) { + unset($_SESSION['admin']); + redirect('/admin/login'); + } + $_SESSION['admin']['authenticated_at'] = time(); + } + + public static function logout(): void + { + unset($_SESSION['admin']); + session_regenerate_id(true); + } +} diff --git a/app/Database.php b/app/Database.php new file mode 100644 index 0000000..ac9b789 --- /dev/null +++ b/app/Database.php @@ -0,0 +1,41 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + return self::$connection; + } +} diff --git a/app/Repositories/AdminRepository.php b/app/Repositories/AdminRepository.php new file mode 100644 index 0000000..f07823d --- /dev/null +++ b/app/Repositories/AdminRepository.php @@ -0,0 +1,189 @@ +db = Database::connection(); + } + + public function dashboardCounts(): array + { + return [ + 'posts' => (int) $this->db->query("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL")->fetchColumn(), + 'published' => (int) $this->db->query("SELECT COUNT(*) FROM posts WHERE status = 'published' AND deleted_at IS NULL")->fetchColumn(), + 'inquiries' => (int) $this->db->query("SELECT COUNT(*) FROM inquiries WHERE deleted_at IS NULL")->fetchColumn(), + 'pending' => (int) $this->db->query("SELECT COUNT(*) FROM inquiries WHERE status IN ('pending','in_progress') AND deleted_at IS NULL")->fetchColumn(), + ]; + } + + public function adminCount(): int + { + return (int) $this->db->query('SELECT COUNT(*) FROM admins')->fetchColumn(); + } + + public function createAdmin(string $username, string $displayName, string $password): void + { + $statement = $this->db->prepare( + 'INSERT INTO admins (username, password_hash, display_name) + VALUES (:username, :password_hash, :display_name)' + ); + $statement->execute([ + 'username' => $username, + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'display_name' => $displayName, + ]); + } + + public function posts(): array + { + return $this->db->query( + "SELECT id, title, slug, category, status, published_at, updated_at + FROM posts WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 100" + )->fetchAll(); + } + + public function inquiries(): array + { + return $this->db->query( + "SELECT id, ticket, name, phone, inquiry_type, title, status, created_at + FROM inquiries WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 100" + )->fetchAll(); + } + + public function findPost(int $id): ?array + { + $statement = $this->db->prepare( + 'SELECT posts.*, + (SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path, + (SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt + FROM posts WHERE posts.id = :id AND posts.deleted_at IS NULL LIMIT 1' + ); + $statement->execute(['id' => $id]); + $post = $statement->fetch(); + return $post ?: null; + } + + public function savePost(array $data, ?int $id = null): int + { + $publishedAt = $data['status'] === 'published' + ? ($data['published_at'] ?: date('Y-m-d H:i:s')) + : null; + + if ($id === null) { + $statement = $this->db->prepare( + 'INSERT INTO posts (title, slug, category, excerpt, body, author, status, published_at) + VALUES (:title, :slug, :category, :excerpt, :body, :author, :status, :published_at)' + ); + $statement->execute([ + 'title'=>$data['title'],'slug'=>$data['slug'],'category'=>$data['category'], + 'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'], + 'status'=>$data['status'],'published_at'=>$publishedAt, + ]); + return (int) $this->db->lastInsertId(); + } + + $statement = $this->db->prepare( + 'UPDATE posts SET title=:title, slug=:slug, category=:category, excerpt=:excerpt, + body=:body, author=:author, status=:status, published_at=:published_at WHERE id=:id AND deleted_at IS NULL' + ); + $statement->execute([ + 'title'=>$data['title'],'slug'=>$data['slug'],'category'=>$data['category'], + 'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'], + 'status'=>$data['status'],'published_at'=>$publishedAt,'id'=>$id, + ]); + return $id; + } + + public function savePostWithImage(array $data, ?int $id, ?string $imagePath, string $imageAlt): array + { + $oldPaths = []; + $this->db->beginTransaction(); + try { + $postId = $this->savePost($data, $id); + if ($imagePath !== null) { + $select = $this->db->prepare('SELECT file_path FROM post_images WHERE post_id = :post_id FOR UPDATE'); + $select->execute(['post_id' => $postId]); + $oldPaths = array_column($select->fetchAll(), 'file_path'); + + $delete = $this->db->prepare('DELETE FROM post_images WHERE post_id = :post_id'); + $delete->execute(['post_id' => $postId]); + $insert = $this->db->prepare( + 'INSERT INTO post_images (post_id, file_path, alt_text, sort_order) + VALUES (:post_id, :file_path, :alt_text, 0)' + ); + $insert->execute(['post_id'=>$postId, 'file_path'=>$imagePath, 'alt_text'=>$imageAlt]); + } elseif ($id !== null) { + $update = $this->db->prepare( + 'UPDATE post_images SET alt_text = :alt_text WHERE post_id = :post_id AND sort_order = 0' + ); + $update->execute(['post_id'=>$postId, 'alt_text'=>$imageAlt]); + } + $this->db->commit(); + return ['id' => $postId, 'old_paths' => $oldPaths]; + } catch (\Throwable $exception) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $exception; + } + } + + public function slugExists(string $slug, ?int $exceptId = null): bool + { + $sql = 'SELECT COUNT(*) FROM posts WHERE slug = :slug AND deleted_at IS NULL'; + $params = ['slug' => $slug]; + if ($exceptId !== null) { + $sql .= ' AND id <> :id'; + $params['id'] = $exceptId; + } + $statement = $this->db->prepare($sql); + $statement->execute($params); + return (int) $statement->fetchColumn() > 0; + } + + public function deletePost(int $id): void + { + $statement = $this->db->prepare('UPDATE posts SET deleted_at = NOW() WHERE id = :id'); + $statement->execute(['id' => $id]); + } + + public function findInquiry(int $id): ?array + { + $statement = $this->db->prepare('SELECT * FROM inquiries WHERE id = :id AND deleted_at IS NULL LIMIT 1'); + $statement->execute(['id' => $id]); + $inquiry = $statement->fetch(); + return $inquiry ?: null; + } + + public function updateInquiry(int $id, string $status, string $reply): void + { + $statement = $this->db->prepare( + 'UPDATE inquiries SET status = :new_status, reply = :reply, + replied_at = CASE WHEN :has_reply = 1 THEN NOW() ELSE replied_at END, + closed_at = CASE + WHEN :closed_status = \'closed\' AND closed_at IS NULL THEN NOW() + WHEN :open_status <> \'closed\' THEN NULL + ELSE closed_at + END + WHERE id = :id AND deleted_at IS NULL' + ); + $statement->execute([ + 'new_status' => $status, + 'closed_status' => $status, + 'open_status' => $status, + 'reply' => $reply === '' ? null : $reply, + 'has_reply' => $reply === '' ? 0 : 1, + 'id' => $id, + ]); + } +} diff --git a/app/Repositories/InquiryRepository.php b/app/Repositories/InquiryRepository.php new file mode 100644 index 0000000..50fc6d2 --- /dev/null +++ b/app/Repositories/InquiryRepository.php @@ -0,0 +1,111 @@ +db = Database::connection(); + } + + public function create(array $data): string + { + do { + $ticket = 'GTS-' . date('Ymd') . '-' . strtoupper(bin2hex(random_bytes(3))); + $check = $this->db->prepare('SELECT COUNT(*) FROM inquiries WHERE ticket = :ticket'); + $check->execute(['ticket' => $ticket]); + } while ((int) $check->fetchColumn() > 0); + + $statement = $this->db->prepare( + "INSERT INTO inquiries + (ticket, name, phone, email, inquiry_type, title, body, password_hash, status, consent_at) + VALUES (:ticket, :name, :phone, :email, :inquiry_type, :title, :body, :password_hash, 'pending', NOW())" + ); + $statement->execute([ + 'ticket' => $ticket, + 'name' => $data['name'], + 'phone' => $data['phone'], + 'email' => $data['email'] ?: null, + 'inquiry_type' => $data['type'], + 'title' => $data['title'], + 'body' => $data['body'], + 'password_hash' => password_hash($data['password'], PASSWORD_DEFAULT), + ]); + + return $ticket; + } + + public function findForLookup(string $ticket): ?array + { + $statement = $this->db->prepare( + 'SELECT id, ticket, inquiry_type, title, body, status, reply, replied_at, created_at, + password_hash, lookup_failed_attempts, lookup_locked_until + FROM inquiries WHERE ticket = :ticket AND deleted_at IS NULL LIMIT 1' + ); + $statement->execute(['ticket' => strtoupper($ticket)]); + $inquiry = $statement->fetch(); + return $inquiry ?: null; + } + + public function resetLookupLimit(int $id): void + { + $statement = $this->db->prepare( + 'UPDATE inquiries + SET lookup_failed_attempts = 0, lookup_locked_until = NULL + WHERE id = :id AND deleted_at IS NULL' + ); + $statement->execute(['id' => $id]); + } + + public function recordLookupFailure(int $id): bool + { + $this->db->beginTransaction(); + try { + $select = $this->db->prepare( + 'SELECT lookup_failed_attempts, lookup_locked_until + FROM inquiries WHERE id = :id AND deleted_at IS NULL FOR UPDATE' + ); + $select->execute(['id' => $id]); + $limit = $select->fetch(); + if (!$limit) { + $this->db->rollBack(); + return false; + } + + $attempts = (int) $limit['lookup_failed_attempts']; + $lockedUntil = $limit['lookup_locked_until']; + if (is_string($lockedUntil) && strtotime($lockedUntil) <= time()) { + $attempts = 0; + } + $attempts++; + $locked = $attempts >= 5; + + $update = $this->db->prepare( + 'UPDATE inquiries + SET lookup_failed_attempts = :attempts, + lookup_locked_until = CASE WHEN :locked = 1 THEN DATE_ADD(NOW(), INTERVAL 15 MINUTE) ELSE NULL END + WHERE id = :id' + ); + $update->execute([ + 'attempts' => $attempts, + 'locked' => $locked ? 1 : 0, + 'id' => $id, + ]); + $this->db->commit(); + return $locked; + } catch (\Throwable $error) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $error; + } + } +} diff --git a/app/Repositories/PostRepository.php b/app/Repositories/PostRepository.php new file mode 100644 index 0000000..634bd45 --- /dev/null +++ b/app/Repositories/PostRepository.php @@ -0,0 +1,81 @@ +db = Database::connection(); + } + + public function latest(int $limit = 3): array + { + $statement = $this->db->prepare( + "SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.published_at, + (SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path, + (SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt + FROM posts + WHERE posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW() + ORDER BY published_at DESC, id DESC + LIMIT :limit" + ); + $statement->bindValue(':limit', $limit, PDO::PARAM_INT); + $statement->execute(); + return $statement->fetchAll(); + } + + public function paginate(int $page, int $perPage, ?string $category = null): array + { + $where = "posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW()"; + $params = []; + if ($category !== null && $category !== '') { + $where .= ' AND posts.category = :category'; + $params['category'] = $category; + } + + $count = $this->db->prepare("SELECT COUNT(*) FROM posts WHERE {$where}"); + $count->execute($params); + $total = (int) $count->fetchColumn(); + + $offset = max(0, ($page - 1) * $perPage); + $query = $this->db->prepare( + "SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.published_at, + (SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path, + (SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt + FROM posts WHERE {$where} + ORDER BY published_at DESC, id DESC + LIMIT :limit OFFSET :offset" + ); + foreach ($params as $key => $value) { + $query->bindValue(':' . $key, $value); + } + $query->bindValue(':limit', $perPage, PDO::PARAM_INT); + $query->bindValue(':offset', $offset, PDO::PARAM_INT); + $query->execute(); + + return ['items' => $query->fetchAll(), 'total' => $total]; + } + + public function findPublishedBySlug(string $slug): ?array + { + $statement = $this->db->prepare( + "SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.body, posts.author, posts.published_at, + (SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path, + (SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt + FROM posts + WHERE posts.slug = :slug AND posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW() + LIMIT 1" + ); + $statement->execute(['slug' => $slug]); + $post = $statement->fetch(); + return $post ?: null; + } +} diff --git a/app/Repositories/SiteSettingRepository.php b/app/Repositories/SiteSettingRepository.php new file mode 100644 index 0000000..ba40aa9 --- /dev/null +++ b/app/Repositories/SiteSettingRepository.php @@ -0,0 +1,42 @@ +db = Database::connection(); + } + + public function all(): array + { + $rows = $this->db->query('SELECT setting_key, setting_value FROM site_settings')->fetchAll(); + $settings = []; + foreach ($rows as $row) $settings[$row['setting_key']] = $row['setting_value']; + return $settings; + } + + public function save(array $settings): void + { + $statement = $this->db->prepare( + 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)' + ); + $this->db->beginTransaction(); + try { + foreach ($settings as $key => $value) $statement->execute(['key'=>$key, 'value'=>$value]); + $this->db->commit(); + } catch (\Throwable $exception) { + $this->db->rollBack(); + throw $exception; + } + } +} diff --git a/app/Support/helpers.php b/app/Support/helpers.php new file mode 100644 index 0000000..ffc41ac --- /dev/null +++ b/app/Support/helpers.php @@ -0,0 +1,134 @@ +'; +} + +function verify_csrf(): void +{ + $submitted = $_POST['_csrf'] ?? ''; + if (!is_string($submitted) || !hash_equals(csrf_token(), $submitted)) { + http_response_code(403); + render('errors/419', ['title' => '요청을 다시 확인해 주세요']); + exit; + } +} + +function redirect(string $path, int $status = 302): never +{ + header('Location: ' . url($path), true, $status); + exit; +} + +function render(string $view, array $data = [], string $layout = 'layouts/site'): void +{ + $viewFile = dirname(__DIR__) . '/Views/' . $view . '.php'; + $layoutFile = dirname(__DIR__) . '/Views/' . $layout . '.php'; + if (!is_file($viewFile) || !is_file($layoutFile)) { + throw new RuntimeException('View not found.'); + } + + extract($data, EXTR_SKIP); + ob_start(); + require $viewFile; + $content = (string) ob_get_clean(); + ob_start(); + require $layoutFile; + $html = (string) ob_get_clean(); + + $basePath = (string) config('app.base_path', ''); + if ($basePath !== '') { + $html = (string) preg_replace_callback( + '/\b(href|src|action)="(\/(?!\/)[^"]*)"/', + static function (array $matches) use ($basePath): string { + $path = $matches[2]; + if ($path === $basePath || str_starts_with($path, $basePath . '/')) return $matches[0]; + return $matches[1] . '="' . $basePath . $path . '"'; + }, + $html + ); + } + echo $html; +} + +function old(string $key, string $default = ''): string +{ + return e($_POST[$key] ?? $default); +} + +function is_active_path(string $path): bool +{ + $current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; + $basePath = (string) config('app.base_path', ''); + if ($basePath !== '' && ($current === $basePath || str_starts_with($current, $basePath . '/'))) { + $current = substr($current, strlen($basePath)) ?: '/'; + } + return $path === '/' ? $current === '/' : str_starts_with($current, $path); +} + +function site_setting(string $key, mixed $default = ''): mixed +{ + static $settings = null; + if ($settings === null) { + try { + $settings = (new \App\Repositories\SiteSettingRepository())->all(); + } catch (\Throwable) { + $settings = []; + } + } + return $settings[$key] ?? $default; +} diff --git a/app/Views/admin/company.php b/app/Views/admin/company.php new file mode 100644 index 0000000..dcf64fe --- /dev/null +++ b/app/Views/admin/company.php @@ -0,0 +1 @@ +
SETTINGS

회사 정보

저장한 정보는 공개 화면의 연락처와 사업자 정보에 반영됩니다.

diff --git a/app/Views/admin/dashboard.php b/app/Views/admin/dashboard.php new file mode 100644 index 0000000..4799b24 --- /dev/null +++ b/app/Views/admin/dashboard.php @@ -0,0 +1 @@ +
OVERVIEW

대시보드

사이트 콘텐츠와 문의 현황입니다.

전체 글공개 글전체 문의처리 대기
diff --git a/app/Views/admin/inquiries/index.php b/app/Views/admin/inquiries/index.php new file mode 100644 index 0000000..a85604d --- /dev/null +++ b/app/Views/admin/inquiries/index.php @@ -0,0 +1 @@ +'대기','in_progress'=>'확인 중','replied'=>'답변 완료','closed'=>'종료']; ?>
INQUIRIES

문의 관리

접수된 상담과 처리 상태를 확인합니다.

· ·

접수된 문의가 없습니다

온라인 상담 문의가 등록되면 이곳에 표시됩니다.

diff --git a/app/Views/admin/inquiries/show.php b/app/Views/admin/inquiries/show.php new file mode 100644 index 0000000..8ba79b6 --- /dev/null +++ b/app/Views/admin/inquiries/show.php @@ -0,0 +1 @@ +'대기','in_progress'=>'확인 중','replied'=>'답변 완료','closed'=>'종료']; ?>← 문의 목록

접수



문의 내용

처리 및 답변

diff --git a/app/Views/admin/login.php b/app/Views/admin/login.php new file mode 100644 index 0000000..0efd3c0 --- /dev/null +++ b/app/Views/admin/login.php @@ -0,0 +1,15 @@ +
+
+ +

관리자 로그인

+

콘텐츠와 문의를 관리하려면 로그인해 주세요.

+
+

+

+
+ + + + +
+
diff --git a/app/Views/admin/posts/form.php b/app/Views/admin/posts/form.php new file mode 100644 index 0000000..3a0313b --- /dev/null +++ b/app/Views/admin/posts/form.php @@ -0,0 +1,23 @@ +
+
CONTENTS

초안으로 저장하거나 공개 상태로 게시합니다.

+
+
입력 내용을 확인해 주세요.
+
+ + +
+ + +
+ +
+ 대표 이미지 + <?= e($post['image_alt']) ?>

새 파일을 선택하면 현재 대표 이미지가 교체됩니다.

+ + +
+ +
+
취소
+
+
글 삭제

게시글을 복구 가능한 삭제 상태로 전환합니다.

diff --git a/app/Views/admin/posts/index.php b/app/Views/admin/posts/index.php new file mode 100644 index 0000000..dec7968 --- /dev/null +++ b/app/Views/admin/posts/index.php @@ -0,0 +1 @@ +
CONTENTS

글 관리

블로그 글을 작성하고 공개 상태를 관리합니다.

새 글 작성
제목분류상태공개일관리
/blog/수정

등록된 글이 없습니다

새 글을 작성해 콘텐츠를 추가해 주세요.

diff --git a/app/Views/admin/setup.php b/app/Views/admin/setup.php new file mode 100644 index 0000000..bccbef2 --- /dev/null +++ b/app/Views/admin/setup.php @@ -0,0 +1,16 @@ +
+
+ +

관리자 계정 등록

+

비밀번호는 서버에 안전하게 해시하여 저장됩니다.

+
+
+
+ + + + + + +
+
diff --git a/app/Views/blog/index.php b/app/Views/blog/index.php new file mode 100644 index 0000000..1765075 --- /dev/null +++ b/app/Views/blog/index.php @@ -0,0 +1,6 @@ +
BLOG · 자료실

시공 이야기 & 알아두면 좋은 정보

네트워크·키폰·CCTV 시공 사례와 장비 선택 정보를 정리했습니다.

+
>전체>
+
<?= e($post['image_alt']) ?>
TECH FIELD NOTE

+

등록된 게시글이 없습니다

선택한 분류의 콘텐츠를 준비하고 있습니다.

+ 1): ?>
>
+
diff --git a/app/Views/blog/show.php b/app/Views/blog/show.php new file mode 100644 index 0000000..3b78717 --- /dev/null +++ b/app/Views/blog/show.php @@ -0,0 +1 @@ +
← 블로그 목록

기술팀

<?= e($post['image_alt']) ?>

비슷한 시공이 필요하신가요?

현장 조건에 맞는 구성을 상담해 드립니다.

상담 신청
diff --git a/app/Views/contact.php b/app/Views/contact.php new file mode 100644 index 0000000..589dd45 --- /dev/null +++ b/app/Views/contact.php @@ -0,0 +1,38 @@ +
CONTACT

1:1 비공개 상담 신청

접수 내용은 담당자만 확인하며 연락처로 회신드립니다.

상담 신청서

* 표시는 필수 입력 항목입니다.

+
입력 내용을 확인해 주세요.
+
+
+
+ 이메일 +
+ + @ + +
+
+
+ +
+ +

개인정보 수집·이용 안내

수집 목적
상담 접수, 문의 확인 및 답변 제공
필수 항목
이름 또는 회사명, 연락처, 문의 유형, 제목, 내용, 조회 PIN
선택 항목
이메일
보유 기간
문의 처리 종료 후 1년

동의를 거부할 권리가 있으나, 필수 항목 수집에 동의하지 않으면 온라인 문의를 접수할 수 없습니다.

+
+ +
+
전화로 바로 상담

경기지점
FAX

+

영업시간


주말·공휴일 휴무

+
+

오시는 길

+

+
+
+

지도를 불러오는 중입니다.

+ +
+ 네이버 지도에서 확인 +
+
+
diff --git a/app/Views/errors/404.php b/app/Views/errors/404.php new file mode 100644 index 0000000..d6000b2 --- /dev/null +++ b/app/Views/errors/404.php @@ -0,0 +1 @@ +
404

주소를 다시 확인하거나 홈으로 이동해 주세요.

홈으로 이동
diff --git a/app/Views/errors/419.php b/app/Views/errors/419.php new file mode 100644 index 0000000..532c741 --- /dev/null +++ b/app/Views/errors/419.php @@ -0,0 +1 @@ +
403

페이지를 새로고침한 뒤 다시 시도해 주세요.

홈으로 이동
diff --git a/app/Views/home.php b/app/Views/home.php new file mode 100644 index 0000000..ae0a6e0 --- /dev/null +++ b/app/Views/home.php @@ -0,0 +1,66 @@ +
+
+
+ 20년 현장 경험 · 통신 인프라 전문 +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 직접 시공팀이 안정적인 통신 환경을 책임집니다.

+
전화상담 1:1 온라인 문의
+ · 견적 상담 무료 +
+
+
+
키폰과 네트워크 장비를 구성한 통신 랙
키폰·네트워크 통합 장비 구성
+
포트별로 정리하고 라벨링한 네트워크 배선 랙
네트워크 배선 정리와 라벨링
+
여러 CCTV 화면을 확인하는 녹화장치와 관제 랙
CCTV 녹화장치·관제 환경 구성
+
건물 옥상에 설치한 옥외 무선 통신 장비
옥외 무선 통신 구간 설치
+
+
+ +
+ + + + +
+ + +
+
+
+
+ +
+
+
20년통신 인프라 업력
1,200+누적 시공 현장
24시간견적 회신 기준
A/S시공 후 사후관리
+
+
+ +
+
SERVICES

전문 시공 서비스

한 곳에서 설계·시공·유지보수를 책임집니다.

+
+ '', + 'keyphone' => '', + 'cctv' => '', + 'maintenance' => '', + ]; + $services = [ + ['네트워크(LAN) 구축', '사무실과 건물의 유선·무선 네트워크를 안정적으로 설계하고 시공합니다.', '네트워크 구축', 'network'], + ['키폰시스템', '사업장 규모에 맞는 주장치와 내선 구성을 제안하고 설치합니다.', '키폰시스템', 'keyphone'], + ['CCTV 보안', '현장에 맞는 카메라와 저장장치, 통합 관제 환경을 구축합니다.', 'CCTV 설치', 'cctv'], + ['시공·유지보수', '정기 점검과 장애 대응까지 통신 인프라를 지속적으로 관리합니다.', '시공·유지보수', 'maintenance'], + ]; + foreach ($services as [$name, $text, $type, $icon]): ?> +

상담 요청
+ +
+
+
+ +
PROCESS

상담부터 사후관리까지

확인 가능한 5단계 과정으로 진행합니다.

  1. 1상담 신청전화 또는 온라인 요청
  2. 2현장 조사환경과 요구사항 확인
  3. 3견적 제안항목별 구성 안내
  4. 4시공안전한 설치와 검수
  5. 5사후관리점검과 장애 대응
+ +
BLOG

시공 이야기와 알아두면 좋은 정보

블로그 더보기
+
<?= e($post['image_alt']) ?>
TECH FIELD NOTE

+

콘텐츠를 준비하고 있습니다

시공 사례와 유용한 정보를 순차적으로 등록하겠습니다.

+
diff --git a/app/Views/inquiries/lookup.php b/app/Views/inquiries/lookup.php new file mode 100644 index 0000000..758193b --- /dev/null +++ b/app/Views/inquiries/lookup.php @@ -0,0 +1 @@ +
INQUIRY

문의 접수 확인

접수번호와 작성 시 입력한 숫자 4자리 조회 PIN을 입력해 주세요.

diff --git a/app/Views/inquiries/show.php b/app/Views/inquiries/show.php new file mode 100644 index 0000000..b589496 --- /dev/null +++ b/app/Views/inquiries/show.php @@ -0,0 +1,2 @@ +'접수 대기','in_progress'=>'확인 중','replied'=>'답변 완료','closed'=>'처리 종료']; ?> +
← 다른 문의 확인

문의 내용

담당자 답변

담당자가 문의를 확인하고 있습니다. 답변 등록 후 이 화면에서 확인할 수 있습니다.

diff --git a/app/Views/inquiries/success.php b/app/Views/inquiries/success.php new file mode 100644 index 0000000..2d54524 --- /dev/null +++ b/app/Views/inquiries/success.php @@ -0,0 +1 @@ +
✓

문의가 접수되었습니다

담당자가 확인 후 입력하신 연락처로 회신드리겠습니다.

접수번호
접수일시
예상 회신
영업일 기준 24시간 이내

접수번호와 작성할 때 입력한 숫자 4자리 조회 PIN으로 처리 상태와 답변을 확인할 수 있습니다.

문의 확인홈으로 이동
diff --git a/app/Views/layouts/admin.php b/app/Views/layouts/admin.php new file mode 100644 index 0000000..0305882 --- /dev/null +++ b/app/Views/layouts/admin.php @@ -0,0 +1,19 @@ + + + + + + + + <?= e($title ?? '관리자') ?> | <?= e(config('app.legal_name')) ?> 관리자 + + + + + + 본문 바로가기 +
관리자 콘솔
사이트 보기
+
>대시보드>글 관리>문의 관리>회사 정보
+
+ + diff --git a/app/Views/layouts/site.php b/app/Views/layouts/site.php new file mode 100644 index 0000000..716b701 --- /dev/null +++ b/app/Views/layouts/site.php @@ -0,0 +1,79 @@ + + + + + + + <?= e($pageTitle) ?> + + + + + + + + + + + + + + + 본문 바로가기 +
+
+ + + + + +
+ >홈 + >블로그 + >1:1 문의 +
+
+ + 상담문의 +
+
+
+ +
+ +
+
+

통신 인프라 구축, 어디서부터 시작할지 고민되세요?

현장 상황에 맞는 상담과 견적을 안내해 드립니다.

+
전화상담 1:1 온라인 문의
+
+
+ +
+
+

네트워크·키폰·CCTV 설계부터 시공·유지보수까지 책임집니다.

+

바로가기

홈블로그1:1 문의개인정보처리방침
+

연락처

대표번호
경기지점
FAX
이메일

+

사업자 정보


사업자등록번호

+
+
© 2026 . All rights reserved.
+
+ + + 전화상담 + + + diff --git a/app/Views/privacy.php b/app/Views/privacy.php new file mode 100644 index 0000000..28eaa29 --- /dev/null +++ b/app/Views/privacy.php @@ -0,0 +1,85 @@ + +
+
+ PRIVACY +

개인정보처리방침

+

(이하 “회사”)는 정보주체의 개인정보를 중요하게 생각하며 「개인정보 보호법」 등 관련 법령을 준수합니다.

+
+ +
+
    +
  1. 처리 목적·항목·보유기간
  2. +
  3. 제3자 제공
  4. +
  5. 처리 위탁
  6. +
  7. 파기
  8. +
  9. 정보주체의 권리
  10. +
  11. 안전성 확보 조치
  12. +
  13. 자동 수집 장치
  14. +
  15. 담당부서·권익침해 구제
  16. +
+
+ +
+

1. 개인정보의 처리 목적, 항목 및 보유기간

+

회사는 다음 목적에 필요한 최소한의 개인정보를 처리합니다.

+
+ + + +
구분처리 목적처리 항목법적 근거보유기간
온라인 상담 문의상담 접수, 문의자 확인, 답변 제공 및 처리 이력 관리필수: 이름 또는 회사명, 연락처, 문의 유형, 제목, 내용, 조회 PIN의 일방향 암호화값
선택: 이메일
자동 생성: 접수번호, 접수·동의·답변·처리 시각
정보주체의 동의
「개인정보 보호법」 제15조 제1항 제1호
문의 처리 종료 후 1년
+
+

관계 법령에 따라 개인정보를 별도로 보존해야 하는 경우에는 해당 법령에서 정한 기간 동안 분리하여 보관합니다.

+
+ +
+

2. 개인정보의 제3자 제공

+

회사는 원칙적으로 정보주체의 개인정보를 제3자에게 제공하지 않습니다. 다만 정보주체가 별도로 동의했거나 법률에 특별한 규정이 있는 경우에는 필요한 범위에서 제공할 수 있습니다.

+
+ +
+

3. 개인정보 처리업무의 위탁

+

회사는 원활한 사이트 운영을 위해 다음 업무를 위탁하고 있으며, 위탁계약 등을 통해 개인정보가 안전하게 관리되도록 필요한 사항을 규정하고 있습니다.

+
수탁업체위탁업무
카페24(주)웹·데이터베이스 호스팅, 데이터 저장 및 백업을 위한 인프라 제공
+

수탁업체 또는 위탁업무가 변경되면 본 처리방침을 통해 공개합니다.

+
+ +
+

4. 개인정보의 파기 절차 및 방법

+

회사는 보유기간이 지나거나 처리 목적을 달성해 개인정보가 불필요하게 된 경우 지체 없이 파기합니다. 온라인 문의는 관리자가 처리 상태를 “종료”로 변경한 시점부터 1년간 보관한 뒤 데이터베이스에서 복구할 수 없도록 삭제합니다. 관계 법령에 따라 보존해야 하는 정보는 다른 개인정보와 분리하여 보관한 후 기간이 끝나면 파기합니다.

+
  • 전자적 파일: 복구 또는 재생할 수 없는 방법으로 영구 삭제
  • 종이 문서가 발생한 경우: 분쇄 또는 소각
+
+ +
+

5. 정보주체와 법정대리인의 권리·의무 및 행사방법

+

정보주체는 회사에 개인정보 열람, 정정·삭제, 처리정지 및 동의 철회를 요구할 수 있습니다. 아래 담당부서에 전화 또는 이메일로 요청하면 본인 확인 후 관련 법령에서 정한 절차에 따라 처리합니다. 법정대리인이나 위임받은 사람을 통해서도 권리를 행사할 수 있으며, 이 경우 위임장 등 정당한 대리권을 확인할 수 있는 자료를 요청할 수 있습니다.

+
+ +
+

6. 개인정보의 안전성 확보 조치

+

회사는 개인정보의 분실·도난·유출·위조·변조 또는 훼손을 방지하기 위해 다음 조치를 시행합니다.

+
  • 개인정보 취급자 및 관리자 접근 권한 제한
  • 조회 PIN과 관리자 비밀번호의 일방향 암호화 저장
  • HTTPS 암호화 통신, 접근 통제 및 조회 시도 제한
  • 보안 프로그램과 서버 소프트웨어의 점검 및 갱신
  • 개인정보 처리시스템 접속기록의 보호와 정기 점검
+
+ +
+

7. 자동으로 수집하는 장치의 설치·운영 및 거부

+

회사는 로그인 상태 유지, 위조 요청 방지 및 문의 중복 접수 방지를 위해 필수 세션 쿠키를 사용합니다. 이 쿠키에는 임의의 세션 식별자만 저장되며 브라우저를 닫으면 만료됩니다. 브라우저 설정에서 쿠키를 차단할 수 있으나, 차단하면 문의 접수와 관리자 기능을 이용하기 어려울 수 있습니다.

+

오시는 길의 네이버 지도를 불러올 때 이용자의 브라우저가 네이버 지도 서비스에 직접 연결되며, 해당 서비스에서 접속 정보 등이 처리될 수 있습니다. 자세한 내용은 네이버 개인정보처리방침에서 확인할 수 있습니다.

+
+ +
+

8. 개인정보 보호 담당부서 및 권익침해 구제

+

개인정보 보호 담당부서

담당
개인정보 보호 담당부서
전화
이메일
+

개인정보 침해에 관한 상담이나 피해 구제가 필요한 경우 다음 기관에 문의할 수 있습니다.

+
  • 개인정보침해신고센터: 국번 없이 118 · privacy.kisa.or.kr
  • 개인정보분쟁조정위원회: 1833-6972 · www.kopico.go.kr
  • 대검찰청: 국번 없이 1301 · www.spo.go.kr
  • 경찰청: 국번 없이 182 · ecrm.police.go.kr
+
+ +
+

9. 개인정보처리방침의 변경

+

이 개인정보처리방침은 2026년 8월 5일부터 적용됩니다. 내용이 변경되는 경우 시행 전에 사이트를 통해 안내합니다.

+

공고일자: 2026년 8월 5일
시행일자: 2026년 8월 5일

+
+
diff --git a/app/bootstrap.php b/app/bootstrap.php new file mode 100644 index 0000000..b704b95 --- /dev/null +++ b/app/bootstrap.php @@ -0,0 +1,39 @@ + 0, + 'path' => (string) (config('app.base_path', '') ?: '/'), + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); +} + +header('X-Content-Type-Options: nosniff'); +header('Referrer-Policy: strict-origin-when-cross-origin'); +header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); diff --git a/bin/create-admin.php b/bin/create-admin.php new file mode 100644 index 0000000..e9ed612 --- /dev/null +++ b/bin/create-admin.php @@ -0,0 +1,35 @@ +prepare( + 'INSERT INTO admins (username, password_hash, display_name) VALUES (:username, :password_hash, :display_name)' +); +$statement->execute([ + 'username' => $username, + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'display_name' => $displayName, +]); +fwrite(STDOUT, "관리자 계정이 생성되었습니다.\n"); diff --git a/bin/migrate-inquiry-pin.php b/bin/migrate-inquiry-pin.php new file mode 100644 index 0000000..1647fa7 --- /dev/null +++ b/bin/migrate-inquiry-pin.php @@ -0,0 +1,27 @@ +exec($statement); + } +} + +fwrite(STDOUT, "문의 조회 PIN 마이그레이션을 적용했습니다.\n"); diff --git a/bin/purge-inquiries.php b/bin/purge-inquiries.php new file mode 100644 index 0000000..58908b4 --- /dev/null +++ b/bin/purge-inquiries.php @@ -0,0 +1,21 @@ +prepare( + 'DELETE FROM inquiries + WHERE closed_at IS NOT NULL + AND closed_at < DATE_SUB(NOW(), INTERVAL 1 YEAR)' +); +$statement->execute(); + +fwrite(STDOUT, sprintf("보유기간이 지난 문의 %d건을 삭제했습니다.\n", $statement->rowCount())); diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..81eb1f1 --- /dev/null +++ b/config/app.php @@ -0,0 +1,29 @@ + '(주)지티에스정보통신', + 'legal_name' => '(주)지티에스정보통신', + 'base_url' => 'https://gtsit.co.kr', + 'base_path' => rtrim((string) (getenv('GTSIT_BASE_PATH') ?: ''), '/'), + 'app_root' => (string) (getenv('GTSIT_APP_ROOT') ?: dirname(__DIR__)), + 'public_root' => (string) (getenv('GTSIT_PUBLIC_ROOT') ?: dirname(__DIR__) . '/public'), + 'noindex' => filter_var(getenv('GTSIT_NOINDEX') ?: false, FILTER_VALIDATE_BOOL), + 'timezone' => 'Asia/Seoul', + 'session_name' => 'gtsit_session', + 'naver_maps' => [ + 'client_id' => (string) (getenv('NAVER_MAPS_CLIENT_ID') ?: '0xuekem2qn'), + 'query' => '서울특별시 송파구 정의로7길 13', + 'latitude' => 37.48524, + 'longitude' => 127.11478, + ], + 'contact' => [ + 'phone' => '1833-4917', + 'phone_sub' => '1833-4918', + 'fax' => '02-6442-0670', + 'email' => 'gts0201@naver.com', + 'address' => '서울시 송파구 정의로7길 13, B동 오피스 1020호', + 'business_hours' => '평일 09:00–18:00', + ], +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..e23bdb9 --- /dev/null +++ b/config/database.php @@ -0,0 +1,36 @@ + $values['DB_HOST'] ?? 'localhost', + 'port' => (int) ($values['DB_PORT'] ?? 3306), + 'name' => $values['DB_NAME'] ?? '', + 'user' => $values['DB_USER'] ?? '', + 'password' => $values['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', +]; diff --git a/database/migrate-20260805-inquiry-pin.sql b/database/migrate-20260805-inquiry-pin.sql new file mode 100644 index 0000000..a6f47b7 --- /dev/null +++ b/database/migrate-20260805-inquiry-pin.sql @@ -0,0 +1,8 @@ +ALTER TABLE inquiries + ADD COLUMN IF NOT EXISTS closed_at DATETIME NULL AFTER replied_at, + ADD COLUMN IF NOT EXISTS lookup_failed_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER closed_at, + ADD COLUMN IF NOT EXISTS lookup_locked_until DATETIME NULL AFTER lookup_failed_attempts; + +ALTER TABLE inquiries + DROP INDEX IF EXISTS idx_inquiries_retention, + ADD INDEX idx_inquiries_retention (closed_at, deleted_at); diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..6cb7cb1 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,71 @@ +CREATE TABLE IF NOT EXISTS admins ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(80) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(100) NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + failed_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0, + locked_until DATETIME NULL, + last_login_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS posts ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + slug VARCHAR(220) NOT NULL UNIQUE, + category VARCHAR(50) NOT NULL, + excerpt VARCHAR(500) NOT NULL DEFAULT '', + body MEDIUMTEXT NOT NULL, + author VARCHAR(100) NOT NULL DEFAULT '(주)지티에스정보통신', + status ENUM('draft', 'published') NOT NULL DEFAULT 'draft', + published_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + INDEX idx_posts_public (status, published_at, deleted_at), + INDEX idx_posts_category (category) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_images ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + post_id BIGINT UNSIGNED NOT NULL, + file_path VARCHAR(500) NOT NULL, + alt_text VARCHAR(255) NOT NULL DEFAULT '', + caption VARCHAR(500) NOT NULL DEFAULT '', + sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_post_images_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + INDEX idx_post_images_order (post_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS inquiries ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ticket VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL, + phone VARCHAR(30) NOT NULL, + email VARCHAR(190) NULL, + inquiry_type VARCHAR(60) NOT NULL, + title VARCHAR(200) NOT NULL, + body TEXT NOT NULL, + password_hash VARCHAR(255) NOT NULL, + status ENUM('pending', 'in_progress', 'replied', 'closed') NOT NULL DEFAULT 'pending', + reply TEXT NULL, + replied_at DATETIME NULL, + closed_at DATETIME NULL, + lookup_failed_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0, + lookup_locked_until DATETIME NULL, + consent_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + INDEX idx_inquiries_status (status, created_at), + INDEX idx_inquiries_retention (closed_at, deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS site_settings ( + setting_key VARCHAR(100) PRIMARY KEY, + setting_value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/seed-drafts.sql b/database/seed-drafts.sql new file mode 100644 index 0000000..24806ba --- /dev/null +++ b/database/seed-drafts.sql @@ -0,0 +1,10 @@ +INSERT IGNORE INTO posts (title, slug, category, excerpt, body, author, status, published_at) VALUES +('사무실 이전 시 네트워크 구축 체크리스트','office-network-move-checklist','네트워크','인터넷 회선 이전부터 배선 경로와 장비 배치까지 이사 전에 확인할 항목입니다.','사무실 이전 2~3주 전에는 인터넷 회선 이전 또는 신규 가입 일정을 먼저 확인해야 합니다.\n\n좌석 배치도를 기준으로 유선 포트 수와 무선 AP 위치, 통신 단자함의 위치를 함께 점검하면 시공 당일 혼선을 줄일 수 있습니다.','(주)지티에스정보통신','draft',NULL), +('LG vs 삼성 키폰 주장치, 어떻게 고를까?','lg-vs-samsung-keyphone-guide','키폰시스템','내선 규모와 확장성, 부가 기능을 기준으로 키폰 주장치 선택 기준을 안내합니다.','키폰 주장치는 현재 회선 수뿐 아니라 향후 증설 가능성을 고려해 선택해야 합니다.\n\n사용 인원과 통화 패턴, 일반 전화기 혼용 여부를 확인한 뒤 환경에 맞는 구성을 결정하는 것이 좋습니다.','(주)지티에스정보통신','draft',NULL), +('물류창고 100회선 키폰과 네트워크 통합 시공기','warehouse-network-keyphone-case','시공사례','넓은 현장의 배선 설계와 무선 구간 보강을 함께 진행한 통합 시공 사례입니다.','대형 물류창고는 높은 천장과 넓은 면적을 고려한 배선 설계가 필요합니다.\n\n구역별 스위치 분산과 무선 AP 채널 설계를 통해 안정성과 유지보수 편의성을 함께 확보했습니다.','(주)지티에스정보통신','draft',NULL), +('유선랜 vs 무선랜, 사무실 환경별 선택 가이드','wired-vs-wireless-office-guide','네트워크','좌석 밀도와 보안 요구에 따라 유선과 무선을 구성하는 기준을 정리했습니다.','유선랜과 무선랜은 대체 관계보다 보완 관계에 가깝습니다.\n\n고정 좌석과 대용량 업무에는 유선을, 회의실과 공용 공간에는 무선을 배치해 안정성과 이동성을 함께 확보합니다.','(주)지티에스정보통신','draft',NULL), +('CCTV 화소수와 저장기간, 적정 기준은?','cctv-resolution-storage-guide','CCTV','현장 목적에 맞는 해상도와 녹화 보관 일수를 선택하는 기준입니다.','CCTV는 설치 목적과 촬영 거리에 맞는 해상도를 선택해야 합니다.\n\n얼굴이나 번호판 식별이 필요한 위치와 전체 동선 확인이 필요한 위치를 구분하면 저장 용량과 비용을 효율적으로 구성할 수 있습니다.','(주)지티에스정보통신','draft',NULL), +('인터넷 속도 저하, 스위치 점검으로 해결한 사례','network-switch-troubleshooting-case','시공사례','내부 네트워크 장비 점검으로 반복되는 속도 저하를 해결한 사례입니다.','인터넷 속도 저하는 통신사 회선뿐 아니라 내부 스위치와 배선에서도 발생할 수 있습니다.\n\n구간별 측정과 장비 점검을 통해 원인을 찾고 필요한 장비만 교체하는 것이 중요합니다.','(주)지티에스정보통신','draft',NULL), +('하이브리드 근무를 위한 사무실 무선랜 설계','hybrid-office-wifi-design','네트워크','동시 접속자 증가에 대비한 AP 수량과 채널 설계 방법입니다.','안정적인 무선 환경을 위해서는 신호 세기뿐 아니라 동시 접속 부하를 분산해야 합니다.\n\n회의실처럼 사용자가 몰리는 공간은 별도 용량을 확보하고 인접 AP의 채널 간섭을 줄입니다.','(주)지티에스정보통신','draft',NULL), +('노후 키폰 교체, 비용을 줄이는 하이브리드 구성','keyphone-hybrid-replacement-guide','키폰시스템','키폰과 일반 전화기를 함께 사용해 교체 비용을 낮추는 구성입니다.','통화량이 많은 자리에는 기능형 키폰을 배치하고 통화가 적은 공간에는 일반 전화기를 사용할 수 있습니다.\n\n자리별 사용 패턴을 기준으로 교체 범위를 정하면 초기 비용을 줄일 수 있습니다.','(주)지티에스정보통신','draft',NULL), +('다지점 매장 CCTV 통합 관제 구축 사례','multi-store-cctv-monitoring-case','CCTV','여러 지점의 영상을 본사에서 확인할 수 있도록 구성한 통합 관제 사례입니다.','지점별 녹화장치와 본사 관제 환경을 연결할 때는 접근 권한과 외부 노출을 함께 고려해야 합니다.\n\n지점 확장 계획을 반영해 처음부터 확장 가능한 구조로 설계하는 것이 좋습니다.','(주)지티에스정보통신','draft',NULL); diff --git a/database/seed-legacy-content.sql b/database/seed-legacy-content.sql new file mode 100644 index 0000000..bf94feb --- /dev/null +++ b/database/seed-legacy-content.sql @@ -0,0 +1,87 @@ +SET NAMES utf8mb4; +START TRANSACTION; + +INSERT INTO posts (title, slug, category, excerpt, body, author, status, published_at) VALUES +( + '사무실 LAN 구축, 구조와 배선 설계부터 확인하세요', + 'office-lan-structure-cabling-guide', + '네트워크', + '사무실 LAN을 안정적으로 운영하려면 좌석 수뿐 아니라 배선 구조, 통신실 위치, 장비 확장성까지 함께 설계해야 합니다.', + 'LAN은 한정된 공간의 컴퓨터와 장비가 데이터를 주고받도록 구성하는 네트워크입니다. 사무실에서는 인터넷 연결뿐 아니라 프린터, 전화, CCTV, 업무 서버 등 여러 장비가 같은 통신 기반을 사용하므로 처음 배선할 때 전체 구조를 함께 살펴야 합니다.\n\n일반적인 사무실은 중앙 통신실의 스위치를 기준으로 각 좌석과 장비를 개별 연결하는 스타형 구성이 관리하기 편합니다. 한 구간에 장애가 생겨도 다른 좌석으로 영향이 번지는 범위를 줄일 수 있고, 포트 추가와 문제 구간 확인도 비교적 수월합니다.\n\n배선 공사 전에는 좌석 배치도, 회의실과 복합기 위치, 무선 AP와 CCTV 설치 지점, 향후 증원 계획을 확인합니다. 현재 필요한 포트 수에만 맞추기보다 예비 회선과 랙 공간을 확보하면 자리 이동이나 장비 증설 때 재시공을 줄일 수 있습니다.\n\n시공 후에는 패치패널과 스위치 포트, 벽면 아웃렛에 같은 번호 체계를 적용하는 것이 중요합니다. 배선 라벨과 연결 정보를 남겨두면 장애 점검 시간이 짧아지고 유지보수 담당자가 바뀌어도 구조를 빠르게 파악할 수 있습니다.', + '(주)지티에스정보통신', + 'published', + '2026-08-05 09:00:00' +), +( + '옥외 무선 통신 구간 설치 시 확인할 사항', + 'outdoor-wireless-link-installation-guide', + '네트워크', + '건물 간 옥외 무선 구간은 가시거리, 고정 방식, 전원과 배선 보호, 접속 보안을 함께 검토해야 안정적으로 운영할 수 있습니다.', + '케이블을 직접 포설하기 어려운 건물 간 구간이나 넓은 외부 공간에서는 옥외 무선 장비를 활용할 수 있습니다. 장비 사양만으로 결정하기보다 두 지점 사이의 거리와 장애물, 설치 높이, 주변 전파 환경을 먼저 확인해야 합니다.\n\n안정적인 통신을 위해서는 송수신 장비 사이의 가시거리를 확보하고 바람이나 진동에도 방향이 틀어지지 않도록 견고하게 고정해야 합니다. 옥외에 노출되는 전원과 통신 배선은 방수 처리와 낙뢰·서지 보호도 함께 검토합니다.\n\n무선 구간은 편리하지만 접속 권한과 암호화 설정이 빠지면 보안 위험이 생길 수 있습니다. 사용 장비가 지원하는 최신 암호화 방식을 적용하고 관리 계정과 비밀번호를 기본값에서 변경하며, 업무망과 방문자망은 용도에 따라 분리하는 편이 안전합니다.\n\n설치가 끝난 뒤에는 실제 사용 위치에서 속도와 지연, 끊김 여부를 확인합니다. 정기 점검 시에는 장비 고정 상태와 케이블 피복, 방수 부위, 신호 품질 변화를 함께 살펴야 장기적인 장애를 예방할 수 있습니다.', + '(주)지티에스정보통신', + 'published', + '2026-08-04 09:00:00' +), +( + '키폰시스템 주장치 선택, 증설 용량이 중요합니다', + 'keyphone-system-capacity-selection', + '키폰시스템', + '키폰 주장치는 현재 사용하는 국선과 내선뿐 아니라 향후 인원 증가, 일반 전화기 혼용, 부가 기능까지 고려해 선택해야 합니다.', + '키폰시스템은 여러 전화번호와 내선을 사업장 구성원들이 효율적으로 사용하고, 걸려온 전화를 필요한 자리로 연결하는 업무용 통신 시스템입니다. 일반적으로 주장치, 키폰 전화기, 연결 선로와 운영에 필요한 부가 장비로 구성됩니다.\n\n주장치를 선택할 때는 현재 사용 중인 전화기 수만 확인해서는 부족합니다. 외부에서 들어오는 국선 수, 필요한 내선 수, 부서 이동과 인원 증가 가능성, 안내방송이나 녹취 같은 부가 기능을 함께 정리해야 합니다. 최대 수용 용량이 부족하면 증설 시 주장치 전체를 교체해야 할 수 있습니다.\n\n모든 자리에 기능형 키폰 전화기가 필요한 것도 아닙니다. 통화량이 많은 안내 데스크와 주요 부서에는 기능형 전화기를 배치하고, 창고나 휴게실처럼 통화가 적은 공간에는 일반 전화기를 혼용할 수 있는지 확인하면 초기 비용을 조절할 수 있습니다.\n\n설치 전에는 기존 배선 재사용 가능 여부와 통신 랙 공간, 정전 시 운영 방식도 점검합니다. 현재 규모와 향후 계획을 함께 알려주시면 필요한 용량을 과도하게 늘리지 않으면서도 확장 가능한 구성을 제안할 수 있습니다.', + '(주)지티에스정보통신', + 'published', + '2026-08-03 09:00:00' +), +( + 'CCTV 녹화장치 구성 시 꼭 확인할 기능', + 'cctv-recorder-essential-features', + 'CCTV', + 'CCTV 녹화장치는 카메라 수와 화질 외에도 움직임 감지, 예약 녹화, 원격 확인, 백업과 보관 정책을 확인해야 합니다.', + 'CCTV 시스템은 카메라 영상의 실시간 확인뿐 아니라 필요한 장면을 안정적으로 저장하고 다시 찾을 수 있어야 합니다. 녹화장치를 구성할 때는 카메라 대수와 해상도, 초당 프레임, 보관 기간을 기준으로 저장 용량을 계산합니다.\n\n움직임 감지 녹화는 변화가 있을 때 영상을 저장해 불필요한 구간을 줄이는 데 도움이 됩니다. 영업시간과 비영업시간을 구분한 예약 녹화, 움직임이나 센서 이벤트에 따른 알림 기능도 현장 운영 방식에 맞춰 설정할 수 있습니다.\n\n외부에서 영상을 확인해야 한다면 원격 접속 편의성만큼 보안 설정이 중요합니다. 불필요한 외부 노출을 줄이고 강한 비밀번호와 사용자별 권한을 적용하며, 운영에 필요한 범위에서만 접속을 허용해야 합니다.\n\n사고 확인이나 자료 제출에 대비해 특정 시간대 영상을 별도 저장하는 백업 절차도 점검합니다. 저장장치 장애 알림, 오래된 영상의 순환 삭제 방식, 실제 검색과 재생 속도까지 설치 후 검수해야 필요한 순간에 영상을 활용할 수 있습니다.', + '(주)지티에스정보통신', + 'published', + '2026-08-02 09:00:00' +), +( + '통신실 랙 배선 정리가 유지보수를 좌우하는 이유', + 'network-rack-cabling-maintenance-case', + '시공사례', + '통신 랙 안의 장비와 배선을 용도별로 정리하고 포트에 라벨을 부여하면 장애 대응과 향후 증설이 쉬워집니다.', + '통신실 랙은 인터넷 회선, 네트워크 스위치, 키폰 주장치와 패치패널 등 사업장의 주요 통신 장비가 모이는 공간입니다. 장비가 정상 동작하더라도 케이블이 뒤엉키고 연결 정보가 남아 있지 않으면 작은 장애도 확인하는 데 많은 시간이 걸립니다.\n\n랙을 정리할 때는 장비의 발열과 점검 공간을 고려해 위치를 정하고, 전원선과 통신선을 가능한 범위에서 구분합니다. 케이블 길이는 점검과 장비 교체에 필요한 여유를 남기되 통풍을 막거나 포트를 가리지 않도록 정리합니다.\n\n패치패널, 스위치 포트, 벽면 아웃렛에는 같은 번호 체계를 사용합니다. 포트별 사용 위치와 용도를 기록해 두면 어느 좌석과 장비가 연결되어 있는지 바로 확인할 수 있어 장애 구간 분리와 자리 이동이 빨라집니다.\n\n시공 완료 후에는 케이블 연결 상태와 링크 속도를 확인하고 랙 전면과 배선 상태를 기록합니다. 이렇게 정리된 기준은 이후 장비 추가나 유지보수 작업의 출발점이 되어 불필요한 재배선과 서비스 중단 시간을 줄여줍니다.', + '(주)지티에스정보통신', + 'published', + '2026-08-01 09:00:00' +) +ON DUPLICATE KEY UPDATE + title = VALUES(title), + category = VALUES(category), + excerpt = VALUES(excerpt), + body = VALUES(body), + author = VALUES(author), + status = VALUES(status), + published_at = VALUES(published_at), + deleted_at = NULL; + +DELETE post_images +FROM post_images +JOIN posts ON posts.id = post_images.post_id +WHERE posts.slug IN ( + 'office-lan-structure-cabling-guide', + 'outdoor-wireless-link-installation-guide', + 'keyphone-system-capacity-selection', + 'cctv-recorder-essential-features', + 'network-rack-cabling-maintenance-case' +); + +INSERT INTO post_images (post_id, file_path, alt_text, caption, sort_order) +SELECT id, '/assets/images/field/lan-cabling-rack.jpg', '포트별로 정리하고 라벨링한 네트워크 배선 랙', '', 0 FROM posts WHERE slug = 'office-lan-structure-cabling-guide' +UNION ALL +SELECT id, '/assets/images/field/outdoor-wireless-link.jpg', '건물 옥상에 설치한 옥외 무선 통신 장비', '', 0 FROM posts WHERE slug = 'outdoor-wireless-link-installation-guide' +UNION ALL +SELECT id, '/assets/images/field/keyphone-network-rack.jpg', '키폰과 네트워크 장비를 구성한 통신 랙', '', 0 FROM posts WHERE slug = 'keyphone-system-capacity-selection' +UNION ALL +SELECT id, '/assets/images/field/cctv-control-rack.jpg', '여러 CCTV 화면을 확인하는 녹화장치와 관제 랙', '', 0 FROM posts WHERE slug = 'cctv-recorder-essential-features' +UNION ALL +SELECT id, '/assets/images/field/lan-cabling-rack.jpg', '정리된 패치패널과 네트워크 스위치 배선', '', 0 FROM posts WHERE slug = 'network-rack-cabling-maintenance-case'; + +COMMIT; diff --git a/design/README.md b/design/README.md new file mode 100644 index 0000000..782693f --- /dev/null +++ b/design/README.md @@ -0,0 +1,22 @@ +# GTS 정보통신 원본 디자인 기반 HTML 페이지 + +원본 번들의 Design Canvas 템플릿과 관리자 기능을 유지한 페이지별 HTML입니다. + +## 공개 페이지 + +- index.html: 홈 +- blog.html: 블로그 목록 +- post-1.html ~ post-9.html: 블로그 상세 +- contact.html: 1:1 문의 + +## 관리자 페이지 + +- admin.html: 관리자 로그인 +- admin-posts.html: 글 관리 목록 +- admin-post-write.html: 새 글 작성 +- admin-post-edit.html: 글 수정 예시 +- admin-inquiries.html: 문의 관리 목록 +- admin-inquiry-detail.html: 문의 답변 화면 +- admin-company.html: 회사 정보 관리 + +관리자 비밀번호는 원본과 동일하게 gts1234입니다. diff --git a/design/admin-company.html b/design/admin-company.html new file mode 100644 index 0000000..20441ab --- /dev/null +++ b/design/admin-company.html @@ -0,0 +1,1941 @@ + + + + +관리자 회사 정보 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/admin-inquiries.html b/design/admin-inquiries.html new file mode 100644 index 0000000..5116775 --- /dev/null +++ b/design/admin-inquiries.html @@ -0,0 +1,1941 @@ + + + + +관리자 문의 관리 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/admin-inquiry-detail.html b/design/admin-inquiry-detail.html new file mode 100644 index 0000000..f7e10cb --- /dev/null +++ b/design/admin-inquiry-detail.html @@ -0,0 +1,1941 @@ + + + + +관리자 문의 답변 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/admin-post-edit.html b/design/admin-post-edit.html new file mode 100644 index 0000000..9af5c71 --- /dev/null +++ b/design/admin-post-edit.html @@ -0,0 +1,1948 @@ + + + + +관리자 글 수정 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/admin-post-write.html b/design/admin-post-write.html new file mode 100644 index 0000000..e204264 --- /dev/null +++ b/design/admin-post-write.html @@ -0,0 +1,1941 @@ + + + + +관리자 새 글 작성 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/admin-posts.html b/design/admin-posts.html new file mode 100644 index 0000000..454f51e --- /dev/null +++ b/design/admin-posts.html @@ -0,0 +1,1941 @@ + + + + +관리자 글 관리 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/admin.html b/design/admin.html new file mode 100644 index 0000000..92ce591 --- /dev/null +++ b/design/admin.html @@ -0,0 +1,1941 @@ + + + + +관리자 로그인 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/assets/2fbd4286-601e-4267-8ad5-83a6f624a684 b/design/assets/2fbd4286-601e-4267-8ad5-83a6f624a684 new file mode 100644 index 0000000..c45fb6a --- /dev/null +++ b/design/assets/2fbd4286-601e-4267-8ad5-83a6f624a684 @@ -0,0 +1,5708 @@ +/* @ds-bundle: {"format":3,"namespace":"MONAFamilyDesignSystem_68e947","components":[{"name":"AppHeader","sourcePath":"uploads/MONA Family Design System-handoff/project/components/app/AppHeader.jsx"},{"name":"LoginScreen","sourcePath":"uploads/MONA Family Design System-handoff/project/components/app/LoginScreen.jsx"},{"name":"SortableHeader","sourcePath":"uploads/MONA Family Design System-handoff/project/components/data/SortableHeader.jsx"},{"name":"Table","sourcePath":"uploads/MONA Family Design System-handoff/project/components/data/Table.jsx"},{"name":"Badge","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/Badge.jsx"},{"name":"EmptyState","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/EmptyState.jsx"},{"name":"EnvChip","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/EnvChip.jsx"},{"name":"ErrorState","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/ErrorState.jsx"},{"name":"ProgressBar","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/ProgressBar.jsx"},{"name":"RoleBadge","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/RoleBadge.jsx"},{"name":"Skeleton","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/Skeleton.jsx"},{"name":"StatusGrid","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/StatusGrid.jsx"},{"name":"Timeline","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/Timeline.jsx"},{"name":"Toast","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/Toast.jsx"},{"name":"ToastStack","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/Toast.jsx"},{"name":"Tooltip","sourcePath":"uploads/MONA Family Design System-handoff/project/components/feedback/Tooltip.jsx"},{"name":"Button","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/Button.jsx"},{"name":"Checkbox","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/Checkbox.jsx"},{"name":"CodeBlock","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/CodeBlock.jsx"},{"name":"CopyButton","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/CopyButton.jsx"},{"name":"IconButton","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/IconButton.jsx"},{"name":"Input","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/Input.jsx"},{"name":"MaskedField","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/MaskedField.jsx"},{"name":"RadioGroup","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/RadioGroup.jsx"},{"name":"Select","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/Select.jsx"},{"name":"Switch","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/Switch.jsx"},{"name":"Textarea","sourcePath":"uploads/MONA Family Design System-handoff/project/components/forms/Textarea.jsx"},{"name":"Breadcrumb","sourcePath":"uploads/MONA Family Design System-handoff/project/components/navigation/Breadcrumb.jsx"},{"name":"Pagination","sourcePath":"uploads/MONA Family Design System-handoff/project/components/navigation/Pagination.jsx"},{"name":"ProjectSwitcher","sourcePath":"uploads/MONA Family Design System-handoff/project/components/navigation/ProjectSwitcher.jsx"},{"name":"Tabs","sourcePath":"uploads/MONA Family Design System-handoff/project/components/navigation/Tabs.jsx"},{"name":"Card","sourcePath":"uploads/MONA Family Design System-handoff/project/components/surfaces/Card.jsx"},{"name":"ConfirmDialog","sourcePath":"uploads/MONA Family Design System-handoff/project/components/surfaces/ConfirmDialog.jsx"},{"name":"EnvBanner","sourcePath":"uploads/MONA Family Design System-handoff/project/components/surfaces/EnvBanner.jsx"},{"name":"Modal","sourcePath":"uploads/MONA Family Design System-handoff/project/components/surfaces/Modal.jsx"},{"name":"StatCard","sourcePath":"uploads/MONA Family Design System-handoff/project/components/surfaces/StatCard.jsx"}],"sourceHashes":{"uploads/MONA Family Design System-handoff/project/components/app/AppHeader.jsx":"6c8b78bb90ad","uploads/MONA Family Design System-handoff/project/components/app/LoginScreen.jsx":"a7541d0392f9","uploads/MONA Family Design System-handoff/project/components/data/SortableHeader.jsx":"f20c60b6e463","uploads/MONA Family Design System-handoff/project/components/data/Table.jsx":"fee9157d5583","uploads/MONA Family Design System-handoff/project/components/feedback/Badge.jsx":"382c4271bc43","uploads/MONA Family Design System-handoff/project/components/feedback/EmptyState.jsx":"36a27b3bbcf8","uploads/MONA Family Design System-handoff/project/components/feedback/EnvChip.jsx":"f207c64207bb","uploads/MONA Family Design System-handoff/project/components/feedback/ErrorState.jsx":"c78f3e17cf38","uploads/MONA Family Design System-handoff/project/components/feedback/ProgressBar.jsx":"00e079f34588","uploads/MONA Family Design System-handoff/project/components/feedback/RoleBadge.jsx":"8f9681d47b2e","uploads/MONA Family Design System-handoff/project/components/feedback/Skeleton.jsx":"fa638ece5ae3","uploads/MONA Family Design System-handoff/project/components/feedback/StatusGrid.jsx":"eb9cf0eb4cf1","uploads/MONA Family Design System-handoff/project/components/feedback/Timeline.jsx":"8cfde353c927","uploads/MONA Family Design System-handoff/project/components/feedback/Toast.jsx":"a5bb2f7feb4a","uploads/MONA Family Design System-handoff/project/components/feedback/Tooltip.jsx":"01ccf23f0807","uploads/MONA Family Design System-handoff/project/components/forms/Button.jsx":"60ff89fa879e","uploads/MONA Family Design System-handoff/project/components/forms/Checkbox.jsx":"2d04cec07990","uploads/MONA Family Design System-handoff/project/components/forms/CodeBlock.jsx":"06c371b22a70","uploads/MONA Family Design System-handoff/project/components/forms/CopyButton.jsx":"ba2768cecca8","uploads/MONA Family Design System-handoff/project/components/forms/IconButton.jsx":"fa21bdcf5aa4","uploads/MONA Family Design System-handoff/project/components/forms/Input.jsx":"e64dba91df63","uploads/MONA Family Design System-handoff/project/components/forms/MaskedField.jsx":"3a26776a6624","uploads/MONA Family Design System-handoff/project/components/forms/RadioGroup.jsx":"740afb99b703","uploads/MONA Family Design System-handoff/project/components/forms/Select.jsx":"528c1fd12d0e","uploads/MONA Family Design System-handoff/project/components/forms/Switch.jsx":"2b661aca2724","uploads/MONA Family Design System-handoff/project/components/forms/Textarea.jsx":"3ba886bbe82f","uploads/MONA Family Design System-handoff/project/components/navigation/Breadcrumb.jsx":"b1ceb08ec25a","uploads/MONA Family Design System-handoff/project/components/navigation/Pagination.jsx":"2482587cd467","uploads/MONA Family Design System-handoff/project/components/navigation/ProjectSwitcher.jsx":"4f1728ea8203","uploads/MONA Family Design System-handoff/project/components/navigation/Tabs.jsx":"c9bec984a757","uploads/MONA Family Design System-handoff/project/components/surfaces/Card.jsx":"3bf56605d926","uploads/MONA Family Design System-handoff/project/components/surfaces/ConfirmDialog.jsx":"be0ad5ddd04e","uploads/MONA Family Design System-handoff/project/components/surfaces/EnvBanner.jsx":"859f4395cba8","uploads/MONA Family Design System-handoff/project/components/surfaces/Modal.jsx":"efb655cffed7","uploads/MONA Family Design System-handoff/project/components/surfaces/StatCard.jsx":"98c5eb626506","uploads/MONA Family Design System-handoff/project/components/util.js":"536a9fc1f4ed","uploads/MONA Family Design System-handoff/project/ui_kits/airsflow/screens.jsx":"797d96a25b88","uploads/MONA Family Design System-handoff/project/ui_kits/files/screens.jsx":"751cf24d37f9","uploads/MONA Family Design System-handoff/project/ui_kits/pass/screens-admin.jsx":"7a521bbc5e8b","uploads/MONA Family Design System-handoff/project/ui_kits/pass/screens-detail.jsx":"85056062aba9","uploads/MONA Family Design System-handoff/project/ui_kits/pass/screens-request-project.jsx":"8260e261f374","uploads/MONA Family Design System-handoff/project/ui_kits/pass/screens-requests.jsx":"18bfb186c971","uploads/MONA Family Design System-handoff/project/ui_kits/pass/screens.jsx":"18d80e0f96cd"},"inlinedExternals":[],"unexposedExports":[{"name":"cx","sourcePath":"uploads/MONA Family Design System-handoff/project/components/util.js"},{"name":"injectStyle","sourcePath":"uploads/MONA Family Design System-handoff/project/components/util.js"}]} */ + +(() => { + +const __ds_ns = (window.MONAFamilyDesignSystem_68e947 = window.MONAFamilyDesignSystem_68e947 || {}); + +const __ds_scope = {}; + +(__ds_ns.__errors = __ds_ns.__errors || []); + +// uploads/MONA Family Design System-handoff/project/components/util.js +try { (() => { +// MONA Family — shared helpers for components. +// Not a component (lowercase, no .d.ts): a tiny runtime util that lets each +// component register its CSS once. Components stay styled by the family tokens +// (var(--…)) and get real :hover / :focus-visible / :disabled states that +// inline styles can't express. + +const _injected = new Set(); +function injectStyle(id, css) { + if (typeof document === 'undefined') return; + if (_injected.has(id)) return; + _injected.add(id); + const el = document.createElement('style'); + el.setAttribute('data-mona', id); + el.textContent = css; + document.head.appendChild(el); +} + +// clsx-lite: join truthy class names. +function cx(...parts) { + return parts.filter(Boolean).join(' '); +} +Object.assign(__ds_scope, { injectStyle, cx }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/util.js", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/data/SortableHeader.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-sortableheader', ` +.mona-sorth { cursor: pointer; user-select: none; } +.mona-sorth__inner { display: inline-flex; align-items: center; gap: 5px; } +.mona-sorth:hover .mona-sorth__inner { color: var(--text-secondary); } +.mona-sorth--active .mona-sorth__inner { color: var(--color-primary); } +.mona-sorth__icon { display: flex; opacity: 0.5; } +.mona-sorth--active .mona-sorth__icon { opacity: 1; } +.mona-sorth--right .mona-sorth__inner { justify-content: flex-end; } +`); +const ICONS = { + asc: /*#__PURE__*/React.createElement("path", { + d: "m18 15-6-6-6 6" + }), + desc: /*#__PURE__*/React.createElement("path", { + d: "m6 9 6 6 6-6" + }), + none: /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("path", { + d: "m7 15 5 5 5-5" + }), /*#__PURE__*/React.createElement("path", { + d: "m7 9 5-5 5 5" + })) +}; + +/** + * SortableHeader — a that cycles asc → desc → none on click and shows + * the matching arrow. Pair with Table. + */ +function SortableHeader({ + label, + direction = 'none', + onSort, + align = 'left', + className, + ...rest +}) { + const next = direction === 'none' ? 'asc' : direction === 'asc' ? 'desc' : 'none'; + return /*#__PURE__*/React.createElement("th", _extends({ + className: __ds_scope.cx('mona-sorth', direction !== 'none' && 'mona-sorth--active', align === 'right' && 'mona-sorth--right', className), + onClick: () => onSort && onSort(next), + "aria-sort": direction === 'asc' ? 'ascending' : direction === 'desc' ? 'descending' : 'none' + }, rest), /*#__PURE__*/React.createElement("span", { + className: "mona-sorth__inner" + }, label, /*#__PURE__*/React.createElement("span", { + className: "mona-sorth__icon", + "aria-hidden": "true" + }, /*#__PURE__*/React.createElement("svg", { + width: "13", + height: "13", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round" + }, ICONS[direction])))); +} +Object.assign(__ds_scope, { SortableHeader }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/data/SortableHeader.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/data/Table.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-table', ` +.mona-table-wrap { width: 100%; overflow-x: auto; border: var(--border-width) solid var(--color-border); border-radius: var(--radius-lg); background: var(--surface-card); } +.mona-table { width: 100%; border-collapse: collapse; font: var(--font-regular) var(--text-sm)/1.4 var(--font-sans); } +.mona-table thead th { + position: sticky; top: 0; z-index: var(--z-sticky); + text-align: left; font-weight: var(--font-semibold); font-size: var(--text-2xs); + text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--text-muted); + background: var(--surface-subtle); padding: 10px 14px; border-bottom: var(--border-width) solid var(--color-border); white-space: nowrap; +} +.mona-table tbody td { padding: 11px 14px; border-bottom: var(--border-width) solid var(--color-border); color: var(--text-body); vertical-align: middle; } +.mona-table tbody tr:last-child td { border-bottom: 0; } +.mona-table--hover tbody tr { transition: background-color var(--duration-fast) var(--ease); } +.mona-table--hover tbody tr:hover td { background: var(--surface-hover); } +.mona-table--zebra tbody tr:nth-child(even) td { background: var(--surface-subtle); } +`); + +/** + * Table — styled wrapper around a native . Provide / + * (use SortableHeader for sortable columns). Sticky header, optional hover + * and zebra striping. + */ +function Table({ + hover = true, + zebra = false, + className, + children, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", { + className: "mona-table-wrap" + }, /*#__PURE__*/React.createElement("table", _extends({ + className: __ds_scope.cx('mona-table', hover && 'mona-table--hover', zebra && 'mona-table--zebra', className) + }, rest), children)); +} +Object.assign(__ds_scope, { Table }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/data/Table.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/Badge.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-badge', ` +.mona-badge { + display: inline-flex; align-items: center; gap: 5px; + font-family: var(--font-sans); font-weight: var(--font-semibold); + border: var(--border-width) solid transparent; white-space: nowrap; +} +.mona-badge--sm { font-size: var(--text-2xs); padding: 2px 7px; } +.mona-badge--md { font-size: var(--text-xs); padding: 3px 9px; } +.mona-badge--square { border-radius: var(--radius-sm); } +.mona-badge--pill { border-radius: var(--radius-pill); } +.mona-badge--upper { text-transform: uppercase; letter-spacing: var(--tracking-wide); } +.mona-badge__dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } + +.mona-badge--neutral { background: var(--surface-sunken); color: var(--text-secondary); border-color: var(--color-border); } +.mona-badge--primary { background: var(--color-primary-subtle); color: var(--color-primary); } +.mona-badge--success { background: var(--color-success-subtle); color: var(--color-success); } +.mona-badge--warning { background: var(--color-warning-subtle); color: var(--color-warning); } +.mona-badge--danger { background: var(--color-danger-subtle); color: var(--color-danger); } +.mona-badge--info { background: var(--color-info-subtle); color: var(--color-info); } +.mona-badge--purple { background: var(--color-role-platform-subtle); color: var(--color-role-platform); } +`); + +/** + * Badge — compact status / category label. Maps onto the semantic palette + * (neutral / primary / success / warning / danger / info / purple). + */ +function Badge({ + variant = 'neutral', + size = 'md', + shape = 'square', + uppercase = false, + dot = false, + className, + children, + ...rest +}) { + return /*#__PURE__*/React.createElement("span", _extends({ + className: __ds_scope.cx('mona-badge', `mona-badge--${variant}`, `mona-badge--${size}`, `mona-badge--${shape}`, uppercase && 'mona-badge--upper', className) + }, rest), dot && /*#__PURE__*/React.createElement("span", { + className: "mona-badge__dot" + }), children); +} +Object.assign(__ds_scope, { Badge }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/Badge.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/EmptyState.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-emptystate', ` +.mona-empty { display: flex; flex-direction: column; align-items: center; text-align: center; } +.mona-empty--sm { padding: 24px 16px; gap: 8px; } +.mona-empty--md { padding: 40px 24px; gap: 10px; } +.mona-empty--lg { padding: 64px 24px; gap: 12px; } +.mona-empty__icon { + display: grid; place-items: center; color: var(--text-disabled); + background: var(--surface-sunken); border-radius: var(--radius-pill); + width: 56px; height: 56px; margin-bottom: 4px; +} +.mona-empty--sm .mona-empty__icon { width: 44px; height: 44px; } +.mona-empty__title { font: var(--font-semibold) var(--text-md)/1.4 var(--font-sans); color: var(--text-body); } +.mona-empty--sm .mona-empty__title { font-size: var(--text-sm); } +.mona-empty__desc { font: var(--font-regular) var(--text-sm)/1.5 var(--font-sans); color: var(--text-muted); max-width: 340px; } +.mona-empty__action { margin-top: 6px; } +`); + +/** + * EmptyState — centered icon + title + description (+ optional action) for + * empty lists, no-results and zero-data screens. The unified family API + * (Files' text-only version is absorbed into this). + */ +function EmptyState({ + icon = null, + title, + description, + action = null, + size = 'md', + className, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-empty', `mona-empty--${size}`, className) + }, rest), icon && /*#__PURE__*/React.createElement("div", { + className: "mona-empty__icon", + "aria-hidden": "true" + }, icon), title && /*#__PURE__*/React.createElement("div", { + className: "mona-empty__title" + }, title), description && /*#__PURE__*/React.createElement("div", { + className: "mona-empty__desc" + }, description), action && /*#__PURE__*/React.createElement("div", { + className: "mona-empty__action" + }, action)); +} +Object.assign(__ds_scope, { EmptyState }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/EmptyState.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/EnvChip.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-envchip', ` +.mona-envchip { display: inline-flex; align-items: center; gap: 6px; font: var(--font-semibold) var(--text-2xs)/1 var(--font-sans); padding: 4px 9px; border-radius: var(--radius-pill); text-transform: uppercase; letter-spacing: var(--tracking-wide); } +.mona-envchip__dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.mona-envchip--dev { background: var(--color-env-dev-subtle); color: var(--color-env-dev); } +.mona-envchip--stage { background: var(--color-env-stage-subtle); color: var(--color-env-stage); } +.mona-envchip--prod { background: var(--color-env-prod-subtle); color: var(--color-env-prod); } +.mona-envchip--plain { padding-left: 8px; } +`); + +/** + * EnvChip — environment label (dev / stage / prod) rendered from the family + * `--color-env-*` tokens. Smaller, inline counterpart to the full-width EnvBanner. + */ +function EnvChip({ + env = 'dev', + dot = true, + children, + className, + ...rest +}) { + return /*#__PURE__*/React.createElement("span", _extends({ + className: __ds_scope.cx('mona-envchip', `mona-envchip--${env}`, !dot && 'mona-envchip--plain', className) + }, rest), dot && /*#__PURE__*/React.createElement("span", { + className: "mona-envchip__dot" + }), children ?? env); +} +Object.assign(__ds_scope, { EnvChip }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/EnvChip.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/ErrorState.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-errorstate', ` +.mona-err { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 56px 24px; gap: 12px; } +.mona-err__icon { display: grid; place-items: center; width: 60px; height: 60px; border-radius: var(--radius-pill); color: var(--color-danger); background: var(--color-danger-subtle); margin-bottom: 4px; } +.mona-err__icon--warning { color: var(--color-warning); background: var(--color-warning-subtle); } +.mona-err__icon--muted { color: var(--text-muted); background: var(--surface-sunken); } +.mona-err__code { font: var(--font-bold) var(--text-2xl)/1 var(--font-mono); color: var(--text-disabled); letter-spacing: var(--tracking-tight); } +.mona-err__title { font: var(--font-bold) var(--text-lg)/1.3 var(--font-sans); color: var(--text-body); } +.mona-err__msg { font: var(--font-regular) var(--text-sm)/1.6 var(--font-sans); color: var(--text-muted); max-width: 420px; } +.mona-err__detail { font: var(--font-regular) var(--text-xs)/1.6 var(--font-mono); color: var(--text-secondary); background: var(--surface-sunken); border-radius: var(--radius-md); padding: 10px 12px; max-width: 460px; overflow: auto; text-align: left; margin-top: 4px; } +.mona-err__actions { display: flex; gap: 10px; margin-top: 8px; } +`); + +/** + * ErrorState — full-page / full-panel error surface, distinct from EmptyState. + * Use for ErrorBoundary fallbacks and proxy errors (401/404/502/504). Optional + * status `code`, monospace `detail` block and retry/refresh actions. + */ +function ErrorState({ + icon = null, + code, + title, + message, + detail, + actions = null, + tone = 'danger', + className, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-err', className) + }, rest), icon && /*#__PURE__*/React.createElement("div", { + className: __ds_scope.cx('mona-err__icon', tone !== 'danger' && `mona-err__icon--${tone}`) + }, icon), code != null && /*#__PURE__*/React.createElement("div", { + className: "mona-err__code" + }, code), title && /*#__PURE__*/React.createElement("div", { + className: "mona-err__title" + }, title), message && /*#__PURE__*/React.createElement("div", { + className: "mona-err__msg" + }, message), detail && /*#__PURE__*/React.createElement("pre", { + className: "mona-err__detail" + }, detail), actions && /*#__PURE__*/React.createElement("div", { + className: "mona-err__actions" + }, actions)); +} +Object.assign(__ds_scope, { ErrorState }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/ErrorState.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/ProgressBar.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-progress', ` +.mona-prog { width: 100%; } +.mona-prog__top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 6px; } +.mona-prog__label { font: var(--font-medium) var(--text-xs)/1.3 var(--font-sans); color: var(--text-secondary); } +.mona-prog__value { font: var(--font-medium) var(--text-2xs)/1 var(--font-mono); color: var(--text-muted); } +.mona-prog__track { width: 100%; height: 8px; background: var(--surface-sunken); border-radius: var(--radius-pill); overflow: hidden; } +.mona-prog__track--sm { height: 5px; } +.mona-prog__track--lg { height: 12px; } +.mona-prog__fill { height: 100%; border-radius: var(--radius-pill); background: var(--color-primary); transition: width var(--duration) var(--ease), background-color var(--duration) var(--ease); } +.mona-prog__fill--success { background: var(--color-success); } +.mona-prog__fill--warning { background: var(--color-warning); } +.mona-prog__fill--danger { background: var(--color-danger); } +.mona-prog__fill--indeterminate { width: 40% !important; animation: mona-loading-bar 1.4s var(--ease) infinite; } +`); + +/** + * ProgressBar / Meter — track + fill for quota usage, cluster utilization and + * upload progress. Pass `threshold` to auto-color (>=90% danger, >=70% warning), + * or set `variant` explicitly. `indeterminate` for unknown-duration loading. + */ +function ProgressBar({ + value = 0, + max = 100, + variant, + threshold = false, + indeterminate = false, + size = 'md', + label, + valueText, + showValue = false, + className, + ...rest +}) { + const pct = max > 0 ? Math.min(100, Math.max(0, value / max * 100)) : 0; + let v = variant || 'primary'; + if (threshold && !variant) v = pct >= 90 ? 'danger' : pct >= 70 ? 'warning' : 'primary'; + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-prog', className) + }, rest), (label || showValue || valueText) && /*#__PURE__*/React.createElement("div", { + className: "mona-prog__top" + }, label && /*#__PURE__*/React.createElement("span", { + className: "mona-prog__label" + }, label), (valueText || showValue) && /*#__PURE__*/React.createElement("span", { + className: "mona-prog__value" + }, valueText ?? `${Math.round(pct)}%`)), /*#__PURE__*/React.createElement("div", { + className: __ds_scope.cx('mona-prog__track', `mona-prog__track--${size}`), + role: "progressbar", + "aria-valuenow": indeterminate ? undefined : Math.round(pct), + "aria-valuemin": 0, + "aria-valuemax": 100 + }, /*#__PURE__*/React.createElement("div", { + className: __ds_scope.cx('mona-prog__fill', `mona-prog__fill--${v}`, indeterminate && 'mona-prog__fill--indeterminate'), + style: indeterminate ? undefined : { + width: `${pct}%` + } + }))); +} +Object.assign(__ds_scope, { ProgressBar }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/ProgressBar.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/RoleBadge.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-rolebadge', ` +.mona-role { + display: inline-flex; align-items: center; + font-family: var(--font-sans); font-weight: var(--font-semibold); + font-size: var(--text-2xs); line-height: 1; + text-transform: uppercase; letter-spacing: var(--tracking-wide); + padding: 4px 9px; border-radius: var(--radius-pill); + border: var(--border-width) solid transparent; +} +.mona-role--admin { background: var(--color-role-admin-subtle); color: var(--color-role-admin); } +.mona-role--developer { background: var(--color-role-developer-subtle); color: var(--color-role-developer); } +.mona-role--viewer { background: var(--color-role-viewer-subtle); color: var(--color-role-viewer); } +.mona-role--platform { background: var(--color-role-platform-subtle); color: var(--color-role-platform); } +`); +const LABELS = { + admin: 'admin', + developer: 'developer', + viewer: 'viewer' +}; + +/** + * RoleBadge — the unified family role chip. Resolves the old per-site + * mismatch (Pass admin=red, AiRSFlow admin=amber) into ONE standard: + * admin=amber, developer=blue, viewer=slate. Global platform-* roles use + * purple and a "P-" prefix. + */ +function RoleBadge({ + role = 'viewer', + isPlatform = false, + className, + ...rest +}) { + const variant = isPlatform ? 'platform' : role; + const label = (isPlatform ? 'P-' : '') + (LABELS[role] || role); + return /*#__PURE__*/React.createElement("span", _extends({ + className: __ds_scope.cx('mona-role', `mona-role--${variant}`, className) + }, rest), label); +} +Object.assign(__ds_scope, { RoleBadge }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/RoleBadge.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/Skeleton.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-skeleton', ` +.mona-skel { display: block; background: var(--surface-sunken); border-radius: var(--radius-sm); position: relative; overflow: hidden; } +.mona-skel::after { + content: ''; position: absolute; inset: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent); + transform: translateX(-100%); animation: mona-loading-bar 1.4s var(--ease) infinite; +} +.dark .mona-skel::after { background: linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent); } +.mona-skel--text { height: 0.75em; border-radius: var(--radius-pill); margin: 0.25em 0; } +.mona-skel--circle { border-radius: 50%; } +`); + +/** + * Skeleton — animated loading placeholder. Compose blocks to mirror the + * shape of content while it loads (Files had no loader — this fills the gap). + */ +function Skeleton({ + width, + height, + variant = 'block', + radius, + className, + style, + ...rest +}) { + const s = { + ...style + }; + if (width != null) s.width = typeof width === 'number' ? `${width}px` : width; + if (height != null) s.height = typeof height === 'number' ? `${height}px` : height; + if (radius != null) s.borderRadius = typeof radius === 'number' ? `${radius}px` : radius; + return /*#__PURE__*/React.createElement("span", _extends({ + className: __ds_scope.cx('mona-skel', variant !== 'block' && `mona-skel--${variant}`, className), + style: s + }, rest)); +} +Object.assign(__ds_scope, { Skeleton }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/Skeleton.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/StatusGrid.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-statusgrid', ` +.mona-sg { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; } +.mona-sg__cell { display: flex; align-items: center; gap: 9px; padding: 11px 13px; border: var(--border-width) solid var(--color-border); border-radius: var(--radius-md); background: var(--surface-card); } +.mona-sg__dot { width: 8px; height: 8px; border-radius: 50%; flex: none; } +.mona-sg__name { font: var(--font-medium) var(--text-sm)/1.2 var(--font-sans); color: var(--text-body); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mona-sg__status { font: var(--font-medium) var(--text-2xs)/1 var(--font-sans); color: var(--text-muted); } +/* ok */ +.mona-sg__cell--ok { border-color: var(--color-success-subtle); background: var(--color-success-subtle); } +.mona-sg__cell--ok .mona-sg__dot { background: var(--color-success); } +.mona-sg__cell--ok .mona-sg__status { color: var(--color-success); } +/* error */ +.mona-sg__cell--error { border-color: var(--color-danger-subtle); background: var(--color-danger-subtle); } +.mona-sg__cell--error .mona-sg__dot { background: var(--color-danger); } +.mona-sg__cell--error .mona-sg__status { color: var(--color-danger); } +/* pending */ +.mona-sg__cell--pending .mona-sg__dot { background: var(--color-warning); } +.mona-sg__cell--pending .mona-sg__status { color: var(--color-warning); } +/* off / disabled */ +.mona-sg__cell--off { opacity: 0.6; } +.mona-sg__cell--off .mona-sg__dot { background: var(--text-disabled); } +`); +const LABELS = { + ok: '정상', + error: '오류', + pending: '대기', + off: '비활성' +}; + +/** + * StatusGrid — compact grid of integration/service health cells (Namespace, + * Keycloak, ArgoCD, Grafana, S3, MLflow…). Each item: {name, status} where + * status is ok / error / pending / off. Optional per-item statusLabel override. + */ +function StatusGrid({ + items = [], + className, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-sg', className) + }, rest), items.map(it => { + const s = it.status || 'ok'; + return /*#__PURE__*/React.createElement("div", { + className: __ds_scope.cx('mona-sg__cell', `mona-sg__cell--${s}`), + key: it.name + }, /*#__PURE__*/React.createElement("span", { + className: "mona-sg__dot" + }), /*#__PURE__*/React.createElement("span", { + className: "mona-sg__name" + }, it.name), /*#__PURE__*/React.createElement("span", { + className: "mona-sg__status" + }, it.statusLabel ?? LABELS[s])); + })); +} +Object.assign(__ds_scope, { StatusGrid }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/StatusGrid.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/Timeline.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-timeline', ` +.mona-tl { display: flex; flex-direction: column; } +.mona-tl__item { display: grid; grid-template-columns: 18px 1fr; gap: 12px; } +.mona-tl__rail { display: flex; flex-direction: column; align-items: center; } +.mona-tl__dot { width: 11px; height: 11px; border-radius: 50%; margin-top: 3px; flex: none; background: var(--color-primary); box-shadow: 0 0 0 3px var(--color-primary-subtle); } +.mona-tl__dot--success { background: var(--color-success); box-shadow: 0 0 0 3px var(--color-success-subtle); } +.mona-tl__dot--warning { background: var(--color-warning); box-shadow: 0 0 0 3px var(--color-warning-subtle); } +.mona-tl__dot--danger { background: var(--color-danger); box-shadow: 0 0 0 3px var(--color-danger-subtle); } +.mona-tl__dot--muted { background: var(--text-disabled); box-shadow: 0 0 0 3px var(--surface-sunken); } +.mona-tl__line { flex: 1; width: 2px; background: var(--color-border); margin: 4px 0; min-height: 8px; } +.mona-tl__body { padding-bottom: 18px; } +.mona-tl__item:last-child .mona-tl__body { padding-bottom: 0; } +.mona-tl__title { font: var(--font-medium) var(--text-sm)/1.4 var(--font-sans); color: var(--text-body); } +.mona-tl__meta { font: var(--font-regular) var(--text-xs)/1.4 var(--font-sans); color: var(--text-muted); margin-top: 2px; } +.mona-tl__desc { font: var(--font-regular) var(--text-sm)/1.5 var(--font-sans); color: var(--text-secondary); margin-top: 4px; } +`); + +/** + * Timeline — vertical event trail for request history and project activity + * logs. Each item has a status dot (primary/success/warning/danger/muted), + * a title, optional meta line and description. + */ +function Timeline({ + items = [], + className, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-tl', className) + }, rest), items.map((it, i) => { + const last = i === items.length - 1; + return /*#__PURE__*/React.createElement("div", { + className: "mona-tl__item", + key: i + }, /*#__PURE__*/React.createElement("div", { + className: "mona-tl__rail" + }, /*#__PURE__*/React.createElement("span", { + className: __ds_scope.cx('mona-tl__dot', it.status && `mona-tl__dot--${it.status}`) + }), !last && /*#__PURE__*/React.createElement("span", { + className: "mona-tl__line" + })), /*#__PURE__*/React.createElement("div", { + className: "mona-tl__body" + }, /*#__PURE__*/React.createElement("div", { + className: "mona-tl__title" + }, it.title), it.meta && /*#__PURE__*/React.createElement("div", { + className: "mona-tl__meta" + }, it.meta), it.description && /*#__PURE__*/React.createElement("div", { + className: "mona-tl__desc" + }, it.description))); + })); +} +Object.assign(__ds_scope, { Timeline }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/Timeline.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/Toast.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-toast', ` +.mona-toast { + display: flex; align-items: flex-start; gap: 11px; + width: 360px; max-width: calc(100vw - 32px); + background: var(--surface-card); border: var(--border-width) solid var(--color-border); + border-radius: var(--radius-lg); box-shadow: var(--shadow-xl); + padding: 13px 14px; animation: mona-fade-in var(--duration) var(--ease-out); +} +.mona-toast__icon { flex: none; margin-top: 1px; display: flex; } +.mona-toast__body { flex: 1; min-width: 0; } +.mona-toast__title { font: var(--font-semibold) var(--text-sm)/1.4 var(--font-sans); color: var(--text-body); } +.mona-toast__msg { font: var(--font-regular) var(--text-xs)/1.5 var(--font-sans); color: var(--text-secondary); margin-top: 2px; word-break: break-word; } +.mona-toast__close { flex: none; border: 0; background: transparent; cursor: pointer; color: var(--text-muted); border-radius: var(--radius-sm); padding: 2px; display: flex; } +.mona-toast__close:hover { background: var(--surface-hover); color: var(--text-body); } +.mona-toast--success .mona-toast__icon { color: var(--color-success); } +.mona-toast--error .mona-toast__icon { color: var(--color-danger); } +.mona-toast--warning .mona-toast__icon { color: var(--color-warning); } +.mona-toast--info .mona-toast__icon { color: var(--color-info); } +.mona-toaststack { position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 10px; z-index: var(--z-toast); } +`); +const ICONS = { + success: /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("path", { + d: "M21.801 10A10 10 0 1 1 17 3.335" + }), /*#__PURE__*/React.createElement("path", { + d: "m9 11 3 3L22 4" + })), + error: /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("circle", { + cx: "12", + cy: "12", + r: "10" + }), /*#__PURE__*/React.createElement("path", { + d: "m15 9-6 6" + }), /*#__PURE__*/React.createElement("path", { + d: "m9 9 6 6" + })), + warning: /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("path", { + d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" + }), /*#__PURE__*/React.createElement("path", { + d: "M12 9v4" + }), /*#__PURE__*/React.createElement("path", { + d: "M12 17h.01" + })), + info: /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("circle", { + cx: "12", + cy: "12", + r: "10" + }), /*#__PURE__*/React.createElement("path", { + d: "M12 16v-4" + }), /*#__PURE__*/React.createElement("path", { + d: "M12 8h.01" + })) +}; +const TITLES = { + success: '완료', + error: '오류', + warning: '경고', + info: '알림' +}; + +/** + * Toast — a single transient notification. Render inside . + * Four variants: success / error / warning / info. + */ +function Toast({ + variant = 'info', + title, + message, + onClose, + className, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-toast', `mona-toast--${variant}`, className), + role: "status" + }, rest), /*#__PURE__*/React.createElement("span", { + className: "mona-toast__icon", + "aria-hidden": "true" + }, /*#__PURE__*/React.createElement("svg", { + width: "18", + height: "18", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round" + }, ICONS[variant])), /*#__PURE__*/React.createElement("div", { + className: "mona-toast__body" + }, /*#__PURE__*/React.createElement("div", { + className: "mona-toast__title" + }, title ?? TITLES[variant]), message && /*#__PURE__*/React.createElement("div", { + className: "mona-toast__msg" + }, message)), onClose && /*#__PURE__*/React.createElement("button", { + className: "mona-toast__close", + "aria-label": "\uB2EB\uAE30", + onClick: onClose + }, /*#__PURE__*/React.createElement("svg", { + width: "15", + height: "15", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round" + }, /*#__PURE__*/React.createElement("path", { + d: "M18 6 6 18" + }), /*#__PURE__*/React.createElement("path", { + d: "m6 6 12 12" + })))); +} + +/** ToastStack — fixed bottom-right container for stacked toasts. */ +function ToastStack({ + children, + className, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-toaststack', className) + }, rest), children); +} +Object.assign(__ds_scope, { Toast, ToastStack }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/Toast.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/feedback/Tooltip.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-tooltip', ` +.mona-tip { position: relative; display: inline-flex; } +.mona-tip__pop { + position: absolute; z-index: var(--z-dropdown); pointer-events: none; + background: var(--zinc-900); color: var(--zinc-50); + font: var(--font-medium) var(--text-xs)/1.3 var(--font-sans); + padding: 6px 9px; border-radius: var(--radius-sm); white-space: nowrap; + box-shadow: var(--shadow-lg); + opacity: 0; transform: translateY(2px); transition: opacity var(--duration-fast) var(--ease), transform var(--duration-fast) var(--ease); +} +.dark .mona-tip__pop { background: var(--zinc-100); color: var(--zinc-900); } +.mona-tip:hover .mona-tip__pop, .mona-tip:focus-within .mona-tip__pop { opacity: 1; transform: translateY(0); } +.mona-tip__pop--top { bottom: calc(100% + 7px); left: 50%; translate: -50% 0; } +.mona-tip__pop--bottom { top: calc(100% + 7px); left: 50%; translate: -50% 0; } +.mona-tip__pop--left { right: calc(100% + 7px); top: 50%; translate: 0 -50%; } +.mona-tip__pop--right { left: calc(100% + 7px); top: 50%; translate: 0 -50%; } +`); + +/** + * Tooltip — lightweight hover/focus label. Wraps a single interactive child; + * CSS-only, no portal. For rich help content use a popover instead. + */ +function Tooltip({ + label, + side = 'top', + className, + children, + ...rest +}) { + return /*#__PURE__*/React.createElement("span", _extends({ + className: __ds_scope.cx('mona-tip', className) + }, rest), children, /*#__PURE__*/React.createElement("span", { + className: `mona-tip__pop mona-tip__pop--${side}`, + role: "tooltip" + }, label)); +} +Object.assign(__ds_scope, { Tooltip }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/feedback/Tooltip.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/forms/Button.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-button', ` +.mona-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 7px; + font-family: var(--font-sans); font-weight: var(--font-semibold); + border: var(--border-width) solid transparent; border-radius: var(--radius-md); + cursor: pointer; white-space: nowrap; text-decoration: none; + transition: background-color var(--duration-fast) var(--ease), + border-color var(--duration-fast) var(--ease), + color var(--duration-fast) var(--ease), + box-shadow var(--duration-fast) var(--ease), + transform var(--duration-fast) var(--ease); +} +.mona-btn:focus-visible { outline: none; box-shadow: var(--focus-ring); } +.mona-btn:active:not(:disabled) { transform: scale(0.97); } +.mona-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.mona-btn--pill { border-radius: var(--radius-pill); } +.mona-btn--block { width: 100%; } + +/* sizes */ +.mona-btn--sm { font-size: var(--text-xs); padding: 0 12px; height: 30px; } +.mona-btn--md { font-size: var(--text-sm); padding: 0 16px; height: 38px; } +.mona-btn--lg { font-size: var(--text-base); padding: 0 22px; height: 46px; } + +/* variants */ +.mona-btn--primary { background: var(--color-primary); color: var(--color-on-primary, var(--text-on-primary)); box-shadow: var(--shadow-sm); } +.mona-btn--primary:hover:not(:disabled) { background: var(--color-primary-hover); } +.mona-btn--primary:active:not(:disabled) { background: var(--color-primary-active); } + +.mona-btn--secondary { background: var(--surface-card); color: var(--text-body); border-color: var(--color-border-strong); } +.mona-btn--secondary:hover:not(:disabled) { background: var(--surface-hover); } + +.mona-btn--ghost { background: transparent; color: var(--text-secondary); } +.mona-btn--ghost:hover:not(:disabled) { background: var(--surface-hover); color: var(--text-body); } + +.mona-btn--danger { background: var(--color-danger); color: #fff; } +.mona-btn--danger:hover:not(:disabled) { filter: brightness(1.06); } + +.mona-btn--danger-outline { background: var(--surface-card); color: var(--color-danger); border-color: var(--color-border-strong); } +.mona-btn--danger-outline:hover:not(:disabled) { background: var(--color-danger-subtle); border-color: var(--color-danger); } +`); + +/** + * Button — the family's primary action control. + * 8px radius by default (set `pill` for the rounded variant). + */ +function Button({ + variant = 'primary', + size = 'md', + pill = false, + block = false, + leadingIcon = null, + trailingIcon = null, + as = 'button', + className, + children, + ...rest +}) { + const Tag = as; + return /*#__PURE__*/React.createElement(Tag, _extends({ + className: __ds_scope.cx('mona-btn', `mona-btn--${variant}`, `mona-btn--${size}`, pill && 'mona-btn--pill', block && 'mona-btn--block', className) + }, rest), leadingIcon, children, trailingIcon); +} +Object.assign(__ds_scope, { Button }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/forms/Button.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/app/LoginScreen.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-login', ` +.mona-login { + min-height: 100%; display: grid; place-items: center; padding: 32px; + background: + radial-gradient(120% 90% at 0% 0%, var(--color-primary-subtle), transparent 55%), + radial-gradient(120% 90% at 100% 100%, var(--color-primary-subtle), transparent 55%), + var(--color-bg); +} +.mona-login__card { + width: 100%; max-width: 400px; background: var(--surface-card); + border: var(--border-width) solid var(--color-border); border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); padding: 32px 30px; +} +.mona-login__logo { width: 52px; height: 52px; border-radius: var(--radius-lg); display: block; margin-bottom: 18px; } +.mona-login__wordmark { font: var(--font-bold) var(--text-xl)/1 var(--font-sans); letter-spacing: var(--tracking-tight); color: var(--text-body); margin-bottom: 20px; } +.mona-login__wordmark b { color: var(--color-primary); font-weight: var(--font-bold); } +.mona-login__title { font: var(--font-bold) var(--text-2xl)/1.25 var(--font-sans); color: var(--text-body); letter-spacing: var(--tracking-tight); } +.mona-login__subtitle { font: var(--font-regular) var(--text-sm)/1.6 var(--font-sans); color: var(--text-secondary); margin-top: 8px; } +.mona-login__action { margin-top: 22px; } +.mona-login__note { font: var(--font-regular) var(--text-xs)/1.6 var(--font-sans); color: var(--text-muted); margin-top: 16px; text-align: center; } +.mona-login__note a { color: var(--color-primary); } +`); +const ArrowRight = /*#__PURE__*/React.createElement("svg", { + width: "17", + height: "17", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round" +}, /*#__PURE__*/React.createElement("path", { + d: "M5 12h14" +}), /*#__PURE__*/React.createElement("path", { + d: "m12 5 7 7-7 7" +})); + +/** + * LoginScreen — the shared SSO entry card (all sites use the same Keycloak). + * Centered card with logo, wordmark, headline, sub-copy and one SSO button. + */ +function LoginScreen({ + logo, + product, + title, + subtitle, + buttonLabel = 'SSO 로그인', + onLogin, + note, + className, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-login', className) + }, rest), /*#__PURE__*/React.createElement("div", { + className: "mona-login__card" + }, logo && /*#__PURE__*/React.createElement("img", { + className: "mona-login__logo", + src: logo, + alt: "" + }), /*#__PURE__*/React.createElement("div", { + className: "mona-login__wordmark" + }, "MONA", /*#__PURE__*/React.createElement("b", null, " ", product)), /*#__PURE__*/React.createElement("h1", { + className: "mona-login__title" + }, title), subtitle && /*#__PURE__*/React.createElement("p", { + className: "mona-login__subtitle" + }, subtitle), /*#__PURE__*/React.createElement("div", { + className: "mona-login__action" + }, /*#__PURE__*/React.createElement(__ds_scope.Button, { + block: true, + size: "lg", + onClick: onLogin, + trailingIcon: ArrowRight + }, buttonLabel)), note && /*#__PURE__*/React.createElement("div", { + className: "mona-login__note" + }, note))); +} +Object.assign(__ds_scope, { LoginScreen }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/app/LoginScreen.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/forms/Checkbox.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-checkbox', ` +.mona-check { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--font-regular) var(--text-sm)/1.4 var(--font-sans); color: var(--text-body); user-select: none; } +.mona-check input { position: absolute; opacity: 0; width: 0; height: 0; } +.mona-check__box { + width: 18px; height: 18px; flex: none; border-radius: 5px; + border: 1.5px solid var(--color-border-strong); background: var(--surface-card); + display: grid; place-items: center; color: #fff; + transition: background-color var(--duration-fast) var(--ease), border-color var(--duration-fast) var(--ease), box-shadow var(--duration-fast) var(--ease); +} +.mona-check__box svg { opacity: 0; transform: scale(0.6); transition: opacity var(--duration-fast) var(--ease), transform var(--duration-fast) var(--ease); } +.mona-check input:checked + .mona-check__box { background: var(--color-primary); border-color: var(--color-primary); } +.mona-check input:checked + .mona-check__box svg { opacity: 1; transform: scale(1); } +.mona-check input:indeterminate + .mona-check__box { background: var(--color-primary); border-color: var(--color-primary); } +.mona-check input:focus-visible + .mona-check__box { box-shadow: var(--focus-ring); } +.mona-check input:disabled ~ * { opacity: 0.5; } +.mona-check--disabled { cursor: not-allowed; } +`); + +/** + * Checkbox — boolean toggle with an animated check. Renders a label when + * `children` are provided. Supports the indeterminate state. + */ +function Checkbox({ + indeterminate = false, + disabled = false, + className, + children, + ...rest +}) { + const ref = React.useRef(null); + React.useEffect(() => { + if (ref.current) ref.current.indeterminate = indeterminate; + }, [indeterminate]); + return /*#__PURE__*/React.createElement("label", { + className: __ds_scope.cx('mona-check', disabled && 'mona-check--disabled', className) + }, /*#__PURE__*/React.createElement("input", _extends({ + ref: ref, + type: "checkbox", + disabled: disabled + }, rest)), /*#__PURE__*/React.createElement("span", { + className: "mona-check__box", + "aria-hidden": "true" + }, /*#__PURE__*/React.createElement("svg", { + width: "12", + height: "12", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "3.5", + strokeLinecap: "round", + strokeLinejoin: "round" + }, /*#__PURE__*/React.createElement("path", { + d: "M20 6 9 17l-5-5" + }))), children && /*#__PURE__*/React.createElement("span", null, children)); +} +Object.assign(__ds_scope, { Checkbox }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/forms/Checkbox.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/forms/CopyButton.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-copybtn', ` +.mona-copy { display: inline-flex; align-items: center; gap: 6px; font: var(--font-medium) var(--text-xs)/1 var(--font-sans); color: var(--text-secondary); background: transparent; border: var(--border-width) solid var(--color-border-strong); border-radius: var(--radius-sm); padding: 5px 9px; cursor: pointer; transition: background-color var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease), border-color var(--duration-fast) var(--ease); } +.mona-copy:hover { background: var(--surface-hover); color: var(--text-body); } +.mona-copy.is-copied { color: var(--color-success); border-color: var(--color-success); } +.mona-copy--on-dark { color: #d4d4d8; border-color: rgba(255,255,255,0.18); } +.mona-copy--on-dark:hover { background: rgba(255,255,255,0.08); color: #fff; } +.mona-copy svg { display: block; } +`); +const COPY = /*#__PURE__*/React.createElement("svg", { + width: "14", + height: "14", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round" +}, /*#__PURE__*/React.createElement("rect", { + width: "14", + height: "14", + x: "8", + y: "8", + rx: "2", + ry: "2" +}), /*#__PURE__*/React.createElement("path", { + d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" +})); +const CHECK = /*#__PURE__*/React.createElement("svg", { + width: "14", + height: "14", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2.4", + strokeLinecap: "round", + strokeLinejoin: "round" +}, /*#__PURE__*/React.createElement("path", { + d: "M20 6 9 17l-5-5" +})); + +/** + * CopyButton — copies `value` to the clipboard and flips to a check for ~1.5s. + * Use `onDark` inside dark code blocks. + */ +function CopyButton({ + value, + label = '복사', + copiedLabel = '복사됨', + onDark = false, + className, + ...rest +}) { + const [copied, setCopied] = React.useState(false); + const copy = () => { + try { + navigator.clipboard && navigator.clipboard.writeText(String(value)); + } catch (e) {/* noop */} + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + return /*#__PURE__*/React.createElement("button", _extends({ + type: "button", + className: __ds_scope.cx('mona-copy', onDark && 'mona-copy--on-dark', copied && 'is-copied', className), + onClick: copy + }, rest), copied ? CHECK : COPY, copied ? copiedLabel : label); +} +Object.assign(__ds_scope, { CopyButton }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/forms/CopyButton.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/forms/CodeBlock.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-codeblock', ` +.mona-code { background: var(--zinc-900); border: var(--border-width) solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; } +.dark .mona-code { background: #0c0c0e; } +.mona-code__bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); } +.mona-code__title { font: var(--font-medium) var(--text-2xs)/1 var(--font-mono); color: #a1a1aa; text-transform: uppercase; letter-spacing: var(--tracking-wide); } +.mona-code__pre { margin: 0; padding: 13px 14px; overflow: auto; font: var(--font-regular) var(--text-xs)/1.7 var(--font-mono); color: #e4e4e7; } +.mona-code__pre .g { color: #4ade80; } +.mona-code__pre .b { color: #60a5fa; } +.mona-code__pre .c { color: #71717a; } +`); + +/** + * CodeBlock — dark monospace block for CLI snippets (kubectl, AWS CLI) and + * config, with an optional title bar and copy button. Pass plain text as + * children, or pre-highlighted nodes. + */ +function CodeBlock({ + title, + copy = true, + copyValue, + className, + children, + ...rest +}) { + const text = copyValue != null ? copyValue : typeof children === 'string' ? children : ''; + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-code', className) + }, rest), (title || copy) && /*#__PURE__*/React.createElement("div", { + className: "mona-code__bar" + }, /*#__PURE__*/React.createElement("span", { + className: "mona-code__title" + }, title), copy && /*#__PURE__*/React.createElement(__ds_scope.CopyButton, { + value: text, + onDark: true + })), /*#__PURE__*/React.createElement("pre", { + className: "mona-code__pre" + }, children)); +} +Object.assign(__ds_scope, { CodeBlock }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/forms/CodeBlock.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/forms/IconButton.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-iconbutton', ` +.mona-iconbtn { + display: inline-flex; align-items: center; justify-content: center; + border: var(--border-width) solid transparent; border-radius: var(--radius-md); + cursor: pointer; color: var(--text-secondary); background: transparent; + transition: background-color var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease), box-shadow var(--duration-fast) var(--ease); +} +.mona-iconbtn svg { display: block; } +.mona-iconbtn:focus-visible { outline: none; box-shadow: var(--focus-ring); } +.mona-iconbtn:disabled { opacity: 0.5; cursor: not-allowed; } +.mona-iconbtn--round { border-radius: var(--radius-pill); } + +.mona-iconbtn--sm { width: 28px; height: 28px; } +.mona-iconbtn--md { width: 34px; height: 34px; } +.mona-iconbtn--lg { width: 40px; height: 40px; } + +.mona-iconbtn--ghost:hover:not(:disabled) { background: var(--surface-hover); color: var(--text-body); } +.mona-iconbtn--secondary { background: var(--surface-card); border-color: var(--color-border); color: var(--text-secondary); } +.mona-iconbtn--secondary:hover:not(:disabled) { background: var(--surface-hover); color: var(--text-body); } +.mona-iconbtn--primary { background: var(--color-primary); color: #fff; } +.mona-iconbtn--primary:hover:not(:disabled) { background: var(--color-primary-hover); } +.mona-iconbtn--danger:hover:not(:disabled) { background: var(--color-danger-subtle); color: var(--color-danger); } +`); + +/** + * IconButton — square/round button for a single icon (toolbar actions, + * theme toggle, close buttons). Always pass an accessible `aria-label`. + */ +function IconButton({ + variant = 'ghost', + size = 'md', + round = false, + className, + children, + ...rest +}) { + return /*#__PURE__*/React.createElement("button", _extends({ + type: "button", + className: __ds_scope.cx('mona-iconbtn', `mona-iconbtn--${variant}`, `mona-iconbtn--${size}`, round && 'mona-iconbtn--round', className) + }, rest), children); +} +Object.assign(__ds_scope, { IconButton }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/forms/IconButton.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/app/AppHeader.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-appheader', ` +.mona-header { + display: flex; align-items: center; gap: 16px; height: var(--header-height); + padding: 0 var(--container-pad); background: var(--surface-card); + border-bottom: var(--border-width) solid var(--color-border); +} +.mona-header__brand { display: inline-flex; align-items: center; gap: 9px; text-decoration: none; flex: none; } +.mona-header__logo { width: 30px; height: 30px; border-radius: var(--radius-md); display: block; } +.mona-header__wordmark { font: var(--font-bold) var(--text-md)/1 var(--font-sans); letter-spacing: var(--tracking-tight); color: var(--text-body); white-space: nowrap; } +.mona-header__wordmark b { color: var(--color-primary); font-weight: var(--font-bold); } +.mona-header__nav { display: flex; align-items: center; gap: 2px; } +.mona-header__navlink { + display: inline-flex; align-items: center; gap: 6px; + font: var(--font-medium) var(--text-sm)/1 var(--font-sans); color: var(--text-secondary); + padding: 8px 11px; border-radius: var(--radius-md); cursor: pointer; text-decoration: none; + transition: background-color var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease); +} +.mona-header__navlink:hover { background: var(--surface-hover); color: var(--text-body); } +.mona-header__navlink--active { background: var(--surface-hover); color: var(--text-body); } +.mona-header__spacer { flex: 1; } +.mona-header__right { display: flex; align-items: center; gap: 8px; } +.mona-header__divider { width: 1px; height: 22px; background: var(--color-border); margin: 0 2px; } +.mona-header__avatar { + width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; + font: var(--font-semibold) var(--text-xs)/1 var(--font-sans); color: #fff; + background: linear-gradient(135deg, var(--color-primary), var(--color-role-platform)); cursor: pointer; +} +`); +const Sun = /*#__PURE__*/React.createElement("svg", { + width: "18", + height: "18", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round" +}, /*#__PURE__*/React.createElement("circle", { + cx: "12", + cy: "12", + r: "4" +}), /*#__PURE__*/React.createElement("path", { + d: "M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" +})); +const Moon = /*#__PURE__*/React.createElement("svg", { + width: "18", + height: "18", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round" +}, /*#__PURE__*/React.createElement("path", { + d: "M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" +})); +function initials(name) { + if (!name) return '·'; + const t = name.trim(); + return t.length <= 2 ? t : t.slice(0, 2); +} + +/** + * AppHeader — the shared family top bar. Logo + wordmark, nav links, a free + * `center` slot (e.g. ProjectSwitcher for Files), theme toggle, custom right + * actions and an avatar. One header, every site. + */ +function AppHeader({ + logo, + product, + nav = [], + activeNav, + onNav, + center = null, + theme = 'light', + onThemeToggle, + rightExtra = null, + user, + onAvatar, + className, + ...rest +}) { + return /*#__PURE__*/React.createElement("header", _extends({ + className: __ds_scope.cx('mona-header', className) + }, rest), /*#__PURE__*/React.createElement("a", { + className: "mona-header__brand", + href: "#", + onClick: e => e.preventDefault() + }, logo && /*#__PURE__*/React.createElement("img", { + className: "mona-header__logo", + src: logo, + alt: "" + }), /*#__PURE__*/React.createElement("span", { + className: "mona-header__wordmark" + }, "MONA", /*#__PURE__*/React.createElement("b", null, " ", product))), nav.length > 0 && /*#__PURE__*/React.createElement("nav", { + className: "mona-header__nav" + }, nav.map(n => { + const id = n.id ?? n.label; + return /*#__PURE__*/React.createElement("a", { + key: id, + className: __ds_scope.cx('mona-header__navlink', id === activeNav && 'mona-header__navlink--active'), + href: n.href || '#', + onClick: e => { + if (!n.href) e.preventDefault(); + onNav && onNav(id); + } + }, n.icon, n.label); + })), center && /*#__PURE__*/React.createElement("div", { + style: { + marginLeft: 4 + } + }, center), /*#__PURE__*/React.createElement("div", { + className: "mona-header__spacer" + }), /*#__PURE__*/React.createElement("div", { + className: "mona-header__right" + }, rightExtra, onThemeToggle && /*#__PURE__*/React.createElement(__ds_scope.IconButton, { + "aria-label": "\uD14C\uB9C8 \uC804\uD658", + onClick: onThemeToggle + }, theme === 'dark' ? Sun : Moon), user && /*#__PURE__*/React.createElement("span", { + className: "mona-header__divider" + }), user && /*#__PURE__*/React.createElement("button", { + className: "mona-header__avatar", + onClick: onAvatar, + title: user.name, + "aria-label": user.name + }, initials(user.name)))); +} +Object.assign(__ds_scope, { AppHeader }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/app/AppHeader.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/forms/Input.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-input', ` +.mona-field { display: flex; flex-direction: column; gap: 6px; } +.mona-field__label { font: var(--font-medium) var(--text-xs)/1.4 var(--font-sans); color: var(--text-secondary); } +.mona-field__label .req { color: var(--color-danger); margin-left: 2px; } +.mona-inputwrap { position: relative; display: flex; align-items: center; } +.mona-inputwrap__icon { position: absolute; left: 12px; display: flex; color: var(--text-muted); pointer-events: none; } +.mona-input { + width: 100%; font: var(--font-regular) var(--text-sm)/1.4 var(--font-sans); + color: var(--text-body); background: var(--surface-card); + border: var(--border-width) solid var(--color-border-strong); border-radius: var(--radius-md); + padding: 9px 12px; transition: border-color var(--duration-fast) var(--ease), box-shadow var(--duration-fast) var(--ease); +} +.mona-inputwrap--with-icon .mona-input { padding-left: 36px; } +.mona-input::placeholder { color: var(--text-disabled); } +.mona-input:hover:not(:disabled):not(:focus) { border-color: var(--text-disabled); } +.mona-input:focus { outline: none; border-color: var(--color-primary); box-shadow: var(--focus-ring); } +.mona-input:disabled { background: var(--surface-sunken); color: var(--text-disabled); cursor: not-allowed; } +.mona-field--error .mona-input { border-color: var(--color-danger); } +.mona-field--error .mona-input:focus { box-shadow: 0 0 0 3px var(--color-danger-subtle); } +.mona-field__hint { font: var(--font-regular) var(--text-xs)/1.4 var(--font-sans); color: var(--text-muted); } +.mona-field__hint--error { color: var(--color-danger); } +`); + +/** + * Input — single-line text field with optional label, leading icon and + * hint / error message. Wraps a native . + */ +function Input({ + label, + hint, + error, + required = false, + leadingIcon = null, + id, + className, + ...rest +}) { + const fieldId = id || (label ? `f-${Math.random().toString(36).slice(2, 8)}` : undefined); + return /*#__PURE__*/React.createElement("div", { + className: __ds_scope.cx('mona-field', error && 'mona-field--error', className) + }, label && /*#__PURE__*/React.createElement("label", { + className: "mona-field__label", + htmlFor: fieldId + }, label, required && /*#__PURE__*/React.createElement("span", { + className: "req" + }, "*")), /*#__PURE__*/React.createElement("div", { + className: __ds_scope.cx('mona-inputwrap', leadingIcon && 'mona-inputwrap--with-icon') + }, leadingIcon && /*#__PURE__*/React.createElement("span", { + className: "mona-inputwrap__icon" + }, leadingIcon), /*#__PURE__*/React.createElement("input", _extends({ + id: fieldId, + className: "mona-input", + "aria-invalid": !!error + }, rest))), (error || hint) && /*#__PURE__*/React.createElement("span", { + className: __ds_scope.cx('mona-field__hint', error && 'mona-field__hint--error') + }, error || hint)); +} +Object.assign(__ds_scope, { Input }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/forms/Input.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/forms/MaskedField.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-masked', ` +.mona-masked { display: flex; flex-direction: column; gap: 6px; } +.mona-masked__label { font: var(--font-medium) var(--text-xs)/1.4 var(--font-sans); color: var(--text-secondary); } +.mona-masked__row { display: flex; align-items: center; gap: 8px; background: var(--surface-sunken); border: var(--border-width) solid var(--color-border); border-radius: var(--radius-md); padding: 8px 10px; } +.mona-masked__val { flex: 1; min-width: 0; font: var(--font-regular) var(--text-sm)/1.4 var(--font-mono); color: var(--text-body); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mona-masked__btn { display: flex; border: 0; background: transparent; color: var(--text-muted); cursor: pointer; padding: 4px; border-radius: var(--radius-sm); } +.mona-masked__btn:hover { background: var(--surface-hover); color: var(--text-body); } +`); +const EYE = /*#__PURE__*/React.createElement("svg", { + width: "15", + height: "15", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round" +}, /*#__PURE__*/React.createElement("path", { + d: "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" +}), /*#__PURE__*/React.createElement("circle", { + cx: "12", + cy: "12", + r: "3" +})); +const EYE_OFF = /*#__PURE__*/React.createElement("svg", { + width: "15", + height: "15", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round" +}, /*#__PURE__*/React.createElement("path", { + d: "M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" +}), /*#__PURE__*/React.createElement("path", { + d: "M14.084 14.158a3 3 0 0 1-4.242-4.242" +}), /*#__PURE__*/React.createElement("path", { + d: "M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" +}), /*#__PURE__*/React.createElement("path", { + d: "m2 2 20 20" +})); + +/** + * MaskedField — reveals/hides a secret (S3 access key, token) with an eye + * toggle and a copy button. Shows a dotted mask until revealed. + */ +function MaskedField({ + label, + value = '', + revealed: controlled, + onToggle, + className, + ...rest +}) { + const [local, setLocal] = React.useState(false); + const shown = controlled != null ? controlled : local; + const toggle = () => { + onToggle ? onToggle(!shown) : setLocal(s => !s); + }; + const masked = '•'.repeat(Math.min(24, Math.max(8, String(value).length))); + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-masked', className) + }, rest), label && /*#__PURE__*/React.createElement("span", { + className: "mona-masked__label" + }, label), /*#__PURE__*/React.createElement("div", { + className: "mona-masked__row" + }, /*#__PURE__*/React.createElement("span", { + className: "mona-masked__val" + }, shown ? value : masked), /*#__PURE__*/React.createElement("button", { + type: "button", + className: "mona-masked__btn", + "aria-label": shown ? '가리기' : '표시', + onClick: toggle + }, shown ? EYE_OFF : EYE), /*#__PURE__*/React.createElement(__ds_scope.CopyButton, { + value: value, + label: "", + copiedLabel: "" + }))); +} +Object.assign(__ds_scope, { MaskedField }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/forms/MaskedField.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/forms/RadioGroup.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-radio', ` +.mona-radiogroup { display: flex; flex-direction: column; gap: 8px; } +.mona-radiogroup--row { flex-direction: row; flex-wrap: wrap; gap: 16px; } +.mona-radio { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; font-family: var(--font-sans); } +.mona-radio--card { border: var(--border-width) solid var(--color-border-strong); border-radius: var(--radius-md); padding: 12px 14px; transition: border-color var(--duration-fast) var(--ease), background-color var(--duration-fast) var(--ease); } +.mona-radio--card.is-checked { border-color: var(--color-primary); background: var(--color-primary-subtle); } +.mona-radio--card.is-checked.is-danger { border-color: var(--color-danger); background: var(--color-danger-subtle); } +.mona-radio input { position: absolute; opacity: 0; width: 0; height: 0; } +.mona-radio__mark { width: 18px; height: 18px; flex: none; margin-top: 1px; border-radius: 50%; border: 1.5px solid var(--color-border-strong); display: grid; place-items: center; transition: border-color var(--duration-fast) var(--ease); } +.mona-radio__mark::after { content: ''; width: 9px; height: 9px; border-radius: 50%; background: var(--color-primary); transform: scale(0); transition: transform var(--duration-fast) var(--ease); } +.mona-radio input:checked + .mona-radio__mark { border-color: var(--color-primary); } +.mona-radio input:checked + .mona-radio__mark::after { transform: scale(1); } +.mona-radio.is-danger input:checked + .mona-radio__mark { border-color: var(--color-danger); } +.mona-radio.is-danger input:checked + .mona-radio__mark::after { background: var(--color-danger); } +.mona-radio input:focus-visible + .mona-radio__mark { box-shadow: var(--focus-ring); } +.mona-radio__body { display: flex; flex-direction: column; gap: 3px; } +.mona-radio__label { font: var(--font-medium) var(--text-sm)/1.3 var(--font-sans); color: var(--text-body); } +.mona-radio__desc { font: var(--font-regular) var(--text-xs)/1.5 var(--font-sans); color: var(--text-muted); } +`); + +/** + * RadioGroup — single-select option set. Plain rows by default; `card` renders + * bordered option cards (used by the destructive delete-policy dialog). Mark an + * option `danger: true` to color its selected card red. + */ +function RadioGroup({ + name, + value, + onChange, + options = [], + variant = 'list', + card = false, + row = false, + className, + ...rest +}) { + const gname = name || React.useId(); + return /*#__PURE__*/React.createElement("div", _extends({ + className: __ds_scope.cx('mona-radiogroup', row && 'mona-radiogroup--row', className), + role: "radiogroup" + }, rest), options.map(o => { + const checked = o.value === value; + return /*#__PURE__*/React.createElement("label", { + key: o.value, + className: __ds_scope.cx('mona-radio', card && 'mona-radio--card', checked && 'is-checked', o.danger && 'is-danger') + }, /*#__PURE__*/React.createElement("input", { + type: "radio", + name: gname, + value: o.value, + checked: checked, + onChange: () => onChange && onChange(o.value) + }), /*#__PURE__*/React.createElement("span", { + className: "mona-radio__mark", + "aria-hidden": "true" + }), /*#__PURE__*/React.createElement("span", { + className: "mona-radio__body" + }, /*#__PURE__*/React.createElement("span", { + className: "mona-radio__label" + }, o.label), o.description && /*#__PURE__*/React.createElement("span", { + className: "mona-radio__desc" + }, o.description))); + })); +} +Object.assign(__ds_scope, { RadioGroup }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "uploads/MONA Family Design System-handoff/project/components/forms/RadioGroup.jsx", error: String((e && e.message) || e) }); } + +// uploads/MONA Family Design System-handoff/project/components/forms/Select.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +__ds_scope.injectStyle('mona-select', ` +.mona-selectwrap { position: relative; display: inline-flex; align-items: center; width: 100%; } +.mona-select { + width: 100%; appearance: none; -webkit-appearance: none; + font: var(--font-regular) var(--text-sm)/1.4 var(--font-sans); color: var(--text-body); + background: var(--surface-card); border: var(--border-width) solid var(--color-border-strong); + border-radius: var(--radius-md); padding: 9px 34px 9px 12px; cursor: pointer; + transition: border-color var(--duration-fast) var(--ease), box-shadow var(--duration-fast) var(--ease); +} +.mona-select:hover:not(:disabled):not(:focus) { border-color: var(--text-disabled); } +.mona-select:focus { outline: none; border-color: var(--color-primary); box-shadow: var(--focus-ring); } +.mona-select:disabled { background: var(--surface-sunken); color: var(--text-disabled); cursor: not-allowed; } +.mona-selectwrap__chev { position: absolute; right: 11px; pointer-events: none; color: var(--text-muted); display: flex; } +`); + +/** + * Select — styled wrapper over a native + + +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+ + + + +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+ + + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + + + + + + + \ No newline at end of file diff --git a/design/contact.html b/design/contact.html new file mode 100644 index 0000000..137b474 --- /dev/null +++ b/design/contact.html @@ -0,0 +1,1941 @@ + + + + +1:1 문의 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/index.html b/design/index.html new file mode 100644 index 0000000..bf17b8f --- /dev/null +++ b/design/index.html @@ -0,0 +1,1941 @@ + + + + +홈 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/post-1.html b/design/post-1.html new file mode 100644 index 0000000..2151d0e --- /dev/null +++ b/design/post-1.html @@ -0,0 +1,1941 @@ + + + + +사무실 이전 시 네트워크 구축 체크리스트 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/post-2.html b/design/post-2.html new file mode 100644 index 0000000..708359a --- /dev/null +++ b/design/post-2.html @@ -0,0 +1,1941 @@ + + + + +LG vs 삼성 키폰 주장치, 어떻게 고를까? | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/post-3.html b/design/post-3.html new file mode 100644 index 0000000..2f32213 --- /dev/null +++ b/design/post-3.html @@ -0,0 +1,1941 @@ + + + + +물류창고 100회선 키폰 + 네트워크 통합 시공기 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/post-4.html b/design/post-4.html new file mode 100644 index 0000000..950f127 --- /dev/null +++ b/design/post-4.html @@ -0,0 +1,1941 @@ + + + + +유선랜 vs 무선랜, 사무실 환경별 선택 가이드 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/post-5.html b/design/post-5.html new file mode 100644 index 0000000..32a7af7 --- /dev/null +++ b/design/post-5.html @@ -0,0 +1,1941 @@ + + + + +CCTV 화소수와 저장기간, 적정 기준은? | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/post-6.html b/design/post-6.html new file mode 100644 index 0000000..fb99b3d --- /dev/null +++ b/design/post-6.html @@ -0,0 +1,1941 @@ + + + + +인터넷 속도 저하, 스위치 점검으로 해결한 사례 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/post-7.html b/design/post-7.html new file mode 100644 index 0000000..98003f3 --- /dev/null +++ b/design/post-7.html @@ -0,0 +1,1941 @@ + + + + +재택·하이브리드 근무를 위한 사무실 무선랜 설계 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/post-8.html b/design/post-8.html new file mode 100644 index 0000000..d268a26 --- /dev/null +++ b/design/post-8.html @@ -0,0 +1,1941 @@ + + + + +노후 키폰 교체, 비용을 줄이는 하이브리드 구성 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/design/post-9.html b/design/post-9.html new file mode 100644 index 0000000..7ea0acb --- /dev/null +++ b/design/post-9.html @@ -0,0 +1,1941 @@ + + + + +다지점 매장 CCTV 통합 관제 구축 사례 | GTS 정보통신 + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + + + + + + +
+ +
+ + + + {{ coPhone }} + + 상담문의 +
+
+
+ + + +
+ + +
+
+
+
+ + + 20년 현장 경험 · 통신 인프라 전문 + +

사무실 네트워크,
처음부터 끝까지 책임집니다

+

LAN 구축부터 키폰·CCTV 시공과 유지보수까지 — 직접 시공팀을 운영하는 GTS 정보통신이 안정적인 통신 환경을 끝까지 책임집니다.

+ +
+ + + 전화상담 {{ coPhone }} + + 1:1 온라인 문의 +
+

평일 09:00–18:00 · 경기지점 {{ coSub }} · 견적 상담 무료

+
+ +
+
+
+ +
현장 시공 이미지 영역
+
+
+
+ + + +
1,200+
누적 시공 현장
+
+
+
+
+ + +
+
+
+
20년
통신 인프라 업력
+
1,200+
누적 시공 현장
+
24시간
견적 회신 기준
+
A/S
시공 후 정기 사후관리
+
+
+
+ + +
+
+ SERVICES +

전문 시공 서비스

+

네트워크부터 키폰·CCTV까지, 한 곳에서 설계·시공·유지보수를 책임집니다.

+
+
+
+ +

네트워크(LAN) 구축

+

사무실·건물의 유선/무선 네트워크를 설계·시공해 끊김 없는 인터넷 환경을 구성합니다.

+
+
+ +

키폰시스템

+

LG·삼성 디지털/아날로그 키폰 주장치 설치와 내선 구성, 하이브리드 구성까지 지원합니다.

+
+
+ +

CCTV 보안

+

매장·사무실 CCTV 설치와 다지점 통합 관제 환경을 구축해 보안을 강화합니다.

+
+
+ +

시공·유지보수

+

통신 인프라 시공부터 정기 점검, 장애 발생 시 신속한 A/S까지 책임집니다.

+
+
+
+ + +
+
+
+ +

직접 시공팀 운영

외주 없이 자사 인력이 시공해 품질이 일정하고 책임 소재가 명확합니다.

+
+
+ +

24시간 내 견적 회신

문의 접수 후 빠르게 현장을 확인하고 항목별 견적을 회신해 드립니다.

+
+
+ +

투명한 비용

불필요한 장비 없이 환경에 맞는 합리적 구성으로 비용을 제안합니다.

+
+
+
+ + + +
+
+ PROCESS +

상담부터 사후관리까지

+

투명하고 예측 가능한 5단계 진행으로 안심하고 맡기실 수 있습니다.

+
+
+
1

상담 신청

전화 또는 온라인으로 간편하게 요청

+
2

현장 조사

환경 확인 및 요구사항 파악

+
3

견적 제안

투명한 항목별 견적 회신

+
4

시공

직접 시공팀이 안전하게 설치

+
5

사후관리

정기 점검과 신속한 A/S

+
+
+
+ + + +
+
+
+
+ BLOG +

시공 이야기 · 알아두면 좋은 정보

+
+ 블로그 더보기 +
+
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
{{ p.date }}
+
+
+
+
+
+
+
+ + +
+
+
+

네트워크 구축, 어디서부터 시작할지 고민되세요?

+

전화 한 통이면 현장 상황에 맞는 무료 견적을 받아보실 수 있습니다.

+
+ + + 전화상담 {{ coPhone }} + + +
+
+
+ +
+
+ + + +
+
BLOG · 자료실
+

시공 이야기 & 알아두면 좋은 정보

+

네트워크·키폰·CCTV 시공 사례와 장비 선택 가이드를 정리했습니다. 궁금한 점은 언제든 상담으로 문의해 주세요.

+ +
+ + + + +
+ +
+ +
+
+ {{ p.thumb }} + {{ p.cat }} +
+
+

{{ p.title }}

+

{{ p.excerpt }}

+
+ {{ p.date }} + 자세히 보기 +
+
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ {{ dCat }} + {{ dDate }} +
+

{{ dTitle }}

+
+ G +
{{ dAuthor }}
GTS 정보통신 기술팀
+
+ +
+
대표 이미지 영역
+
+
+ +
{{ galFirstNode }}
+
+ +
+
+
+ +
{{ g.node }}
+
+
+ + +
{{ galCounter }}
+
+
+ + + + +
+
+
+
+ +

{{ para }}

+
+
+ +
+
비슷한 시공이 필요하신가요?
현장에 맞는 무료 견적을 받아보세요.
+
+ 전화상담 {{ coPhone }} + 1:1 문의 +
+
+ +
+

다른 글

+
+ +
+ {{ r.cat }} +

{{ r.title }}

+
{{ r.date }}
+
+
+
+
+
+
+ + + +
+
CONTACT
+

1:1 비공개 상담 신청

+

남겨주신 내용은 비공개로 접수되며, 담당자가 확인 후 연락처로 회신드립니다. 급하신 경우 대표번호로 전화 주시면 바로 상담해 드립니다.

+ +
+ +
+ +
+ +

문의가 접수되었습니다

+

담당자가 확인 후 빠르게 연락드리겠습니다.
접수 내용은 입력하신 비밀번호로 확인하실 수 있습니다.

+
접수번호{{ ticket }}
+
+ 새 문의 작성 + 홈으로 +
+
+
+ +

상담 신청서

+

* 표시는 필수 입력 항목입니다.

+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ 개인정보 수집·이용에 동의합니다. (상담 답변 목적, 보관 후 파기) +
+
+ 1:1 문의 접수하기 +
+
+
+ + +
+
+
전화로 바로 상담
+ + + {{ coPhone }} + +
경기지점 {{ coSub }}
FAX {{ coFax }}
+
+ +
+
영업시간
+

평일 09:00 – 18:00
주말 · 공휴일 휴무

+
+ +
+
오시는 길
+

{{ coAddr1 }}
{{ coAddr2 }}

+
지도 영역
+
+ +
+ +

1:1 비공개 문의는 작성하신 비밀번호로 본인만 답변을 확인할 수 있습니다.

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

관리자 로그인

+

콘텐츠 · 문의 · 회사 정보 관리를 위해
관리자 비밀번호를 입력하세요.

+
+
+
+
+ +
비밀번호가 올바르지 않습니다.
+
+
+ 로그인 +
+
+ + 데모 비밀번호 · gts1234 +
+
+
+
+ +
+
+
+ + GTS 관리자 콘솔 + admin +
+ 로그아웃 + 사이트로 돌아가기 +
+
+ +
+

콘텐츠 관리

+ + +
+ + + + + + +
+ + + +
+
전체 {{ postCount }}개의 글
+ + 새 글 작성 +
+
+
+
제목
카테고리
작성일
관리
+
+ +
+
{{ p.title }}
+
{{ p.cat }}
+
{{ p.date }}
+
+ 수정 + 삭제 +
+
+
+
+
+ + + +
+

새 글 작성

+

글 수정

+

작성한 글은 이 브라우저에 저장되어 블로그 목록과 상세 화면에 바로 노출됩니다.

+
+ +
+ + +
+
+ +
+ +
+ {{ im.node }} + + {{ im.num }} +
+
+ +
+

여러 장을 올리면 글 상세에서 좌우로 넘겨볼 수 있습니다. 업로드 시 자동으로 크기가 최적화됩니다.

+
+
+ +
+
+ +
+
+ 저장하기 + 취소 +
+
+
+
+ + + + +
접수된 문의 {{ inquiryCount }}건
+ +
+ +
아직 접수된 문의가 없습니다
+
1:1 문의 폼으로 접수된 상담이 여기에 표시됩니다.
+
+
+
+ +
+
+ {{ q.status }} + {{ q.type }} + {{ q.ticket }} +
+

{{ q.title }}

+
{{ q.contact }} · {{ q.date }}
+

{{ q.body }}

+
+ 응대하기 + 답변 완료 +
+
+
+
+
+ + + +
+
+
+ {{ cqStatus }} + {{ cqType }} + {{ cqTicket }} +
+

{{ cqTitle }}

+
+
이름 · {{ cqName }}
+
연락처 · {{ cqPhone }}
+
이메일 · {{ cqEmail }}
+
접수일 · {{ cqDate }}
+
+

{{ cqBody }}

+
+
+

답변 작성

+

{{ cqRepliedAt }} 답변 등록됨

+ +
+ 답변 등록 + 대기로 변경 +
+
+
+
+
+ + + +
+

회사 정보 설정

+

여기서 수정한 내용은 헤더·푸터·문의 화면 등 사이트 전체에 바로 반영됩니다.

+
+ +
+ {{ cdLogoImg }} + + + 기본 아이콘으로 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ 회사 정보 저장 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+ {{ footerLogo }} + {{ coBrandA }} {{ coBrandB }} +
+

{{ coTagline }}

+
+
+

바로가기

+
+ + + +
+
+
+

연락처

+
대표번호 {{ coPhone }}
경기지점 {{ coSub }}
FAX {{ coFax }}
이메일 {{ coEmail }}
+
+
+

사업자 정보

+
{{ coLegal }}
대표 {{ coCeo }}
사업자등록번호 {{ coBizno }}
{{ coAddr1 }} {{ coAddr2 }}
+
+
+
+ © 2026 GTS Network. All rights reserved. + +
+
+
+ + + + + + 전화상담 + + + +
+
+ + + + \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3382a9d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# GTSIT 사이트 문서 + +사이트 구축, Cafe24 배포, 도메인 연결, 데이터베이스 설정 과정을 한곳에서 관리합니다. + +- [디자인 구현 전 검토](./design-review.md): 페이지별 구현 우려점, 수정 우선순위, 확정 필요 항목 + +## 문서 목록 + +- [사이트 설계안](./architecture.md): 페이지 구조, 기술 구성, 데이터 모델, 배포 및 보안 원칙 +- [구현 TODO](./implementation-todo.md): 단계별 작업 순서와 담당자 체크리스트 +- [QA 체크리스트](./qa-list.md): 임시 페이지부터 운영 사이트까지 이어서 사용할 검증 항목 + +## 현재 목표 + +1. 임시 안내 페이지를 Cafe24 기본 도메인에 배포 +2. 실제 보유 도메인을 호스팅에 연결하고 DNS 정상화 +3. HTTPS 인증서 적용 +4. MariaDB 접속 및 기본 스키마 준비 +5. `design/` 시안을 기준으로 본 사이트 구현 + +## 상태 표기 + +- `[x]`: 완료 및 확인됨 +- `[ ]`: 미진행 또는 재확인 필요 +- `[사용자]`: 사용자가 직접 수행하거나 결정해야 하는 항목 +- `[Codex]`: 사용자 확인 후 Codex가 수행할 수 있는 항목 +- `[공동]`: 화면 확인이나 의사결정이 함께 필요한 항목 + +## 문서 관리 원칙 + +- 프로젝트 작업자는 루트 `AGENTS.md`의 문서 동기화 규칙을 따른다. +- 작업 완료 즉시 `implementation-todo.md`의 체크박스와 날짜 갱신 +- QA 수행 시 `qa-list.md` 실행 기록에 URL, 환경, 결과, 증빙 기록 +- 호스팅 비밀번호, DB 비밀번호, SSH 개인키, 인증번호는 문서와 Git에 기록하지 않음 +- 호스팅 또는 도메인 설정을 실제로 변경하기 전 대상과 영향 범위를 재확인 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..cfb007b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,197 @@ +# GTSIT 사이트 설계안 + +최종 수정일: 2026-08-10 + +## 1. 구축 목표 + +- 회사와 서비스를 명확히 소개하는 반응형 웹사이트 구축 +- 게시글과 문의를 운영자가 관리할 수 있는 관리자 기능 제공 +- Cafe24 웹호스팅의 PHP 및 MariaDB 환경에 맞춘 단순하고 유지보수 가능한 구성 +- 비밀번호와 문의 내용 등 민감정보를 브라우저 저장소가 아닌 서버에서 안전하게 처리 + +## 2. 확인된 운영 환경 + +| 항목 | 확인 내용 | 후속 작업 | +| --- | --- | --- | +| 호스팅 계정 | `gts0201` | 인증 정보는 문서에 저장하지 않음 | +| 상품 | 뉴아우토반호스팅 일반형 | 현재 용량으로 초기 운영 가능 | +| 웹 환경 | PHP 8.4 | 배포 전 필수 PHP 확장 확인 | +| DB 환경 | MariaDB 10.6.17, `localhost:3306`, 계정 `gts0201` | `utf8mb4_unicode_ci`, 스키마 생성 대기 | +| 서버 IP | `112.175.85.162` | DNS 생성 후 실제 응답과 대조 | +| 기본 도메인 | `gts0201.mycafe24.com` | 연결 도메인으로 유지, 운영 노출 정책 결정 대기 | +| 대표 도메인 | `gtsit.co.kr` | 2026-08-04 Cafe24 대표 도메인 변경 완료 | +| 대표 이메일 | `gts0201@naver.com` | 공개 화면 및 문의 안내에 사용 | +| 회사 주소 | `서울시 송파구 정의로7길 13, B동 오피스 1020호` | 공개 화면 사업자 정보에 사용 | +| 네임서버 | Cafe24 네임서버 위임 확인 | 호스팅 연결 후 DNS 레코드 생성 확인 | +| 현재 DNS | 루트·`www`가 `112.175.85.162`로 연결, 공개 DNS 전파 중 | Cloudflare DNS 전파 재확인 | +| SSL | Let’s Encrypt 적용, 루트·`www` 포함 | 자동 갱신 상태 지속 확인 | +| 웹 방화벽 | ModSecurity 사용 | 적용 후 기능 오탐 점검 | +| UploadGuard | 사용 | 파일 업로드 기능과 호환성 점검 | +| SSH/SFTP | RSA 4096 인증키, 만료일 2026-09-03 | 새 다운로드 키로 접속 성공 | +| 서버 홈·웹 루트 | `/home/hosting_users/gts0201`, `www` | 임시 페이지 배포 완료 | +| PHP 안티웹쉘 | 미사용 | 본 사이트 배포 전 활성화 검토 | +| 외부 MySQL 접속 | 차단 | 서버 내부 PHP에서만 DB 사용 권장 | + +## 3. 페이지 구조 + +### 공개 페이지 + +| 경로 | 역할 | 주요 구성 | +| --- | --- | --- | +| `/` | 홈 | 회사 소개, 핵심 서비스, 최신 게시글, 문의 유도 | +| `/blog` | 게시글 목록 | 검색 또는 분류, 목록, 페이지네이션 | +| `/blog/{slug}` | 게시글 상세 | 제목, 본문, 이미지, 작성일, 이전·다음 글 | +| `/contact` | 문의 접수 | 작성자 정보, 문의 내용, 개인정보 동의 | +| `/inquiry/lookup` | 문의 확인 | 접수번호와 숫자 4자리 조회 PIN을 통한 본인 확인 | + +### 관리자 페이지 + +| 경로 | 역할 | 주요 구성 | +| --- | --- | --- | +| `/admin/login` | 관리자 인증 | 서버 세션 기반 로그인 | +| `/admin` | 대시보드 | 게시글 및 문의 요약 | +| `/admin/posts` | 게시글 관리 | 목록, 상태 변경, 수정, 삭제 | +| `/admin/posts/new` | 게시글 작성 | 제목, 본문, 대표 이미지, 공개 상태 | +| `/admin/posts/{id}/edit` | 게시글 수정 | 기존 데이터 편집 | +| `/admin/inquiries` | 문의 관리 | 목록, 검색, 처리 상태 | +| `/admin/inquiries/{id}` | 문의 상세 | 내용 확인, 답변 메모, 상태 변경 | +| `/admin/company` | 회사 정보 | 연락처와 사이트 공통 정보 관리 | + +## 4. 권장 기술 구성 + +- 서버: PHP 8.4 +- 데이터베이스: MariaDB 10.6.17, PDO MySQL과 prepared statement 사용 +- 화면: 서버 렌더링 HTML, CSS, 필요한 범위의 경량 JavaScript +- 인증: PHP 세션, `password_hash()`와 `password_verify()` 사용 +- URL: Apache rewrite 사용 가능 여부 확인 후 의미 있는 경로 적용 +- 설정: DB 접속값을 공개 디렉터리 밖의 환경별 설정 파일로 분리 + +확인된 웹 PHP 환경은 PHP 8.4.21p1, UTF-8, `Asia/Seoul`, PDO MySQL, mysqli, mbstring, OpenSSL, fileinfo, 업로드 최대 100MB, 메모리 256MB입니다. DB 접속 설정은 서버 웹 루트 밖의 `.gtsit-db.env`에 권한 600으로 저장합니다. + +Cafe24의 실제 웹 루트는 `/home/hosting_users/gts0201/www`로 확인했습니다. PHP 확장은 본 사이트 개발 단계에서 확정합니다. 아래 구조는 공유호스팅 권한 범위에 맞춰 조정합니다. + +```text +app/ + Controllers/ + Repositories/ + Views/ +config/ +database/ + schema.sql +docs/ +public/ + assets/ + uploads/ + index.php +storage/ + logs/ +``` + +초기 임시 배포는 현재의 `public/index.html` 단일 파일을 유지합니다. 본 사이트는 `public/index.php` 프런트 컨트롤러와 공통 PHP 뷰로 구현하며, 운영 전환 시 Apache가 `index.php`를 우선 사용합니다. + +## 5. 데이터 모델 초안 + +| 테이블 | 목적 | 핵심 데이터 | +| --- | --- | --- | +| `admins` | 관리자 계정 | 로그인 ID, 비밀번호 해시, 상태, 마지막 로그인 | +| `posts` | 게시글 | 제목, slug, 본문, 공개 상태, 작성·수정일 | +| `post_images` | 게시글 이미지 | 게시글 ID, 파일 경로, 대체 텍스트, 정렬값 | +| `inquiries` | 고객 문의 | 접수번호, 작성자, 연락처, 내용, 조회 PIN 해시, 처리 상태, 조회 실패·잠금, 종료일 | +| `site_settings` | 회사 및 공통 설정 | 설정 키, 값, 수정일 | + +2026-08-04 초기 스키마를 MariaDB에 적용했습니다. `admins`, `posts`, `post_images`, `inquiries`, `site_settings` 5개 테이블은 모두 `utf8mb4_unicode_ci`를 사용합니다. 문의는 접수번호와 숫자 4자리 조회 PIN의 해시로 공개 답변을 조회합니다. 2026-08-05 접수번호별 조회 실패 횟수와 잠금 시각, 처리 종료일을 추가했으며 5회 실패 시 15분 동안 해당 접수번호의 조회를 제한합니다. + +## 5-1. 현재 구현 구조 + +```text +app/ + Auth/AdminAuth.php + Repositories/ + Support/helpers.php + Views/ +bin/create-admin.php +config/ +database/schema.sql +public/ + assets/css/ + assets/images/company-logo-v3.png + assets/js/ + index.html + index.php +``` + +- 공개 페이지: 서버 렌더링, 페이지별 title·description·canonical·Open Graph 적용 +- 브랜드 로고: 투명 배경 `GTS` 워드마크 PNG를 이미지로 사용하고, 법인명은 정확한 표기와 접근성을 위해 HTML 텍스트로 분리 +- 서비스 아이콘: 네트워크·키폰·CCTV·유지보수 항목에 대응하는 인라인 SVG를 고정 UI 자산으로 관리하며 관리자 입력 대상에서 제외 +- 메인 비주얼: 기존 사이트 백업의 실제 시공 이미지 4장을 `public/assets/images/field/`에 1200×900 JPEG로 최적화해 5초 간격 자동 슬라이드로 표시 +- 슬라이드 접근성: 이전·다음·직접 선택·일시정지 제공, 포인터·키보드 조작 중 자동 전환 정지, `prefers-reduced-motion` 환경 자동재생 중지 +- 이전 콘텐츠 이관: LAN·옥외 무선·키폰·CCTV·통신 랙 관리 5개 주제를 현재 표현으로 재구성하고 `database/seed-legacy-content.sql`로 멱등 반영 +- 문의: 이메일은 아이디와 주요 도메인을 조합하고 `직접입력`에서만 전체 주소 작성, 연락처는 단일 필드에서 휴대전화·서울·기타 지역번호·대표번호·인터넷전화·안심번호 형식으로 자동 변환 +- 문의 보안: 서버 필수값 검증, CSRF, 20초 중복 방지, 숫자 4자리 조회 PIN 해시, 세션 및 접수번호별 조회 실패 제한 +- 개인정보: 2026 개인정보보호위원회 작성지침을 기준으로 처리 목적·항목·법적 근거·보유기간·제3자 제공·위탁·파기·권리 행사·안전조치·세션 쿠키·담당부서·시행일을 공개 +- 개인정보 파기: 문의가 `closed`로 전환된 시각을 기록하고 1년이 지난 문의를 `bin/purge-inquiries.php`로 데이터베이스에서 완전 삭제 +- 파기 실행 환경: 현재 뉴아우토반 공유 웹호스팅은 cron을 지원하지 않아 정리 명령을 수동 실행할 수 있도록 배포했으며, 외부 스케줄러 또는 지원 상품 전환 시 정기 실행 등록 필요 +- 관리자: 서버 세션, 비밀번호 해시, 계정별 잠금, 세션 단위 시도 제한, 모든 하위 경로 인증 선행 +- 초기 관리자: 24시간 유효한 토큰을 세션으로 교환한 뒤 비밀번호를 직접 설정하며, 생성 즉시 토큰 파일 삭제 +- 게시글: slug, 분류, 초안·공개 상태, 소프트 삭제 기반 +- 대표 이미지: JPG·PNG·WebP, 8MB, 6000px 제한, MIME 재검증, 임의 파일명, 대체 텍스트 적용 +- 이미지 없는 게시글 카드: 글을 숨기지 않고 `GTS` 로고와 카테고리 색상을 사용한 16:9 브랜드 플레이스홀더 표시, 대표 이미지 등록 시 실제 이미지 우선 +- 회사 정보: 관리자 화면에서 연락처·주소·사업자 정보를 DB 설정값으로 관리 +- 오시는 길: NAVER Cloud Dynamic Map으로 주소 기반 지도와 회사 마커 표시, API 오류 시 네이버 지도 검색 링크 제공 +- 운영 임시 페이지: 본 사이트 배포 전까지 `public/index.html` 유지 +- 운영 테스트 경로: `/site-preview-7c2a91f4`, 애플리케이션은 웹 루트 밖에 두고 공개 에셋과 프런트 컨트롤러만 노출 + +## 6. 디자인 구현 전 수정 원칙 + +- 19개 HTML 시안의 공통 헤더, 푸터, 내비게이션을 공통 뷰로 통합 +- 게시글 상세 페이지를 개별 정적 파일이 아닌 하나의 동적 템플릿으로 통합 +- 공개 화면의 모바일 헤더 가로 넘침 수정 +- 관리자 게시글 목록의 모바일 조작 영역 잘림 수정 +- `localStorage` 기반 게시글과 문의 데이터를 MariaDB로 이전 +- 클라이언트 코드에 포함된 관리자 인증값 제거 +- 문의 등록 시 접수번호와 조회 PIN 해시를 서버에 저장하고 접수번호별 시도 제한을 적용한 조회 절차 구현 +- 업로드 파일의 확장자, MIME, 용량, 저장 파일명을 서버에서 검증 + +## 7. 보안 원칙 + +- 모든 운영 페이지 HTTPS 강제 +- 관리자 로그인 시도 제한과 세션 쿠키 보안 옵션 적용 +- CSRF 토큰, 출력 이스케이프, SQL 파라미터 바인딩 적용 +- 비밀번호와 개인키를 저장소, 로그, HTML, JavaScript에 포함하지 않음 +- 업로드 디렉터리에서 PHP 실행 차단 +- 관리자 경로에 검색엔진 차단 및 필요 시 추가 접근 제한 적용 +- Cafe24 일일 백업과 별도의 외부 백업 병행 + +## 8. 배포 순서 + +1. SSH/SFTP 개인키 준비 및 서버 접속 확인 +2. 기존 웹 루트 파일 확인 및 백업 +3. 임시 `index.html` 업로드 +4. `gts0201.mycafe24.com`에서 응답 및 모바일 화면 확인 +5. 확정한 사용자 도메인을 호스팅에 연결 +6. 루트 도메인과 `www` DNS 응답 확인 +7. 무료 SSL 신청, 발급, HTTPS 리디렉션 확인 +8. MariaDB 접속 확인 및 최소 권한 구성 +9. 본 사이트 스키마와 애플리케이션 배포 +10. 보안, 백업, 알림, 최종 회귀 QA + +정식 전환 전에는 운영 루트를 임시 안내 페이지로 유지하고 `https://gtsit.co.kr/site-preview-7c2a91f4/`에서 테스트합니다. 경로 접두사는 링크, 폼, 리디렉션, 업로드, 세션 쿠키에 일관되게 적용하며 검색엔진 색인을 차단합니다. + +## 9. 미확정 의사결정 + +현재 대표 주소는 `https://gtsit.co.kr`입니다. Cafe24 대표 도메인도 `gtsit.co.kr`로 변경했으며, HTTP와 `www` 요청은 `public/.htaccess`에서 대표 주소로 301 리디렉션합니다. 2026-08-04 확인한 Let’s Encrypt 인증서는 루트와 `www`를 포함하며 2026-11-02까지 유효합니다. +- 회사 대표 메일을 Cafe24 메일로 사용할지 외부 메일 서비스를 사용할지 결정 +- 관리자 계정 수와 권한 구분 필요 여부 확정 +- 게시글 분류, 검색, 임시저장, 예약발행 필요 여부 확정 + +## 10. 네이버 지도 연동 + +- NAVER Cloud Platform의 Maps Application에서 `Dynamic Map` 선택 +- Web 서비스 URL은 공식 형식에 맞춰 `http://gtsit.co.kr` 등록(HTTP·HTTPS 구분 없음, `www` 제외) +- 브라우저 지도 로더에는 Client ID를 `ncpKeyId`로 사용 +- Client Secret은 프런트엔드, 저장소, HTML에 포함하지 않음 +- 고정 회사 위치는 확인된 위도 `37.48524`, 경도 `127.11478`을 사용해 `Dynamic Map`만 호출하고, 별도 Geocoding 상품과 일일 주소 변환 호출은 사용하지 않음 +- 주소 표시는 `서울시 송파구 정의로7길 13, B동 오피스 1020호`, 네이버 지도 검색은 건물 주소를 기준으로 연결 +- 지도 SDK가 컨테이너 위치 속성을 덮어써도 높이가 유지되도록 지도 요소에 `height: 100%` 적용 +- NAVER 기본 컨트롤에 전체화면이 없으므로 Fullscreen API 기반 사용자 정의 아이콘을 지도 안에 제공하고, 미지원 환경에서는 네이버 지도 새 창 열기로 대체 +- 정상 지도 상태에서는 별도 텍스트 링크를 숨기고, API 로드 실패 또는 JavaScript 비활성 환경에서만 주소가 지정된 네이버 지도 링크 제공 diff --git a/docs/design-review.md b/docs/design-review.md new file mode 100644 index 0000000..a4f7c5a --- /dev/null +++ b/docs/design-review.md @@ -0,0 +1,117 @@ +# 디자인 구현 전 검토 + +검토일: 2026-08-04 +대상: `design/`의 공개 페이지 12개, 관리자 페이지 7개, 공통 에셋 + +## 결론 + +현재 디자인의 색상, 타이포그래피, 카드 구성, 홈의 정보 흐름은 구현 기준으로 사용할 수 있습니다. 다만 `design/*.html`은 화면 확인용 프로토타입이므로 운영 코드로 복사하거나 그대로 배포하면 안 됩니다. 공개 화면은 공통 PHP 템플릿과 시맨틱 HTML로 다시 구성하고, 관리자 인증·문의·게시글·회사 정보는 서버와 MariaDB를 통해 처리해야 합니다. + +구현 착수 전 아래 P0 항목을 설계에 반영합니다. + +1. 관리자 인증 및 모든 관리자 URL의 서버 측 접근 통제 +2. 문의 접수·답변 확인 방식 확정과 개인정보 동의 문구 보완 +3. 모바일 헤더·관리자 목록의 별도 레이아웃 추가 +4. 회사 정보, 연락처, 실적 수치, 이미지 원본 확정 +5. 페이지별 중복 프로토타입을 공통 템플릿 구조로 전환 + +## P0: 구현 전에 반드시 수정 + +### 관리자 인증 + +- 모든 관리자 상세 HTML에 `adminAuthed: true`가 설정되어 있어 URL 직접 접근만으로 관리 화면이 노출됩니다. +- 시안용 관리자 비밀번호가 모든 HTML과 `design/README.md`에 평문으로 포함되어 있습니다. +- 인증 판단이 브라우저 JavaScript에서 이루어져 비밀번호를 바꾸더라도 우회할 수 있습니다. +- 운영 구현에서는 비밀번호 해시, 서버 세션, 로그인 시도 제한, 로그아웃, CSRF 방어를 적용합니다. +- `/admin` 및 하위 URL은 검색엔진 제외와 서버 측 인증 검사를 모두 적용합니다. 공개 푸터의 관리자 진입 버튼은 제거합니다. + +### 데이터 저장 + +- 게시글, 문의, 회사 정보가 `localStorage`에만 저장되어 다른 브라우저·기기·관리자에게 공유되지 않습니다. +- 문의자가 접수해도 관리자가 다른 브라우저에서 볼 수 없고, 브라우저 데이터 삭제 시 내용이 사라집니다. +- 운영 구현에서는 MariaDB를 단일 원본으로 사용하고 게시글 이미지 파일만 웹 루트 밖 또는 보호된 업로드 경로에 저장합니다. +- 디자인에 포함된 샘플 문의에는 이름, 연락처, 이메일 등 실제 개인정보처럼 보이는 값이 있으므로 운영 데이터에 포함하지 않습니다. + +### 문의와 개인정보 흐름 + +- 화면은 문의 비밀번호로 답변을 확인할 수 있다고 안내하지만 비밀번호는 필수가 아니며 저장도 되지 않고, 공개 답변 조회 화면도 없습니다. +- 문의 내용은 필수로 표시되지만 현재 제출 검증에서는 누락되어 빈 내용도 접수됩니다. +- 이름·연락처 형식, 본문 길이, 스팸 요청, 반복 제출에 대한 검증이 없습니다. +- 개인정보 동의 문구에 수집 항목, 이용 목적, 보유 기간, 동의 거부권과 불이익, 개인정보처리방침 연결을 명확히 표시해야 합니다. +- 초기 출시 권장안은 전화·이메일 회신 방식으로 단순화하고 공개 답변 조회 비밀번호를 제거하는 것입니다. 웹 답변 조회가 필요하면 별도의 `접수번호 + 비밀번호` 조회 페이지와 비밀번호 해시 저장, 시도 제한을 설계합니다. + +### 반응형 구조 + +- 공개 헤더가 로고, 3개 메뉴, 전화번호, 상담 버튼을 한 줄 고정 배치해 모바일 너비에서 넘칩니다. 햄버거 메뉴 또는 2단 모바일 헤더가 필요합니다. +- 홈 히어로의 `minmax(340px, 1fr)`는 320px급 화면에서 가로 넘침 가능성이 있습니다. +- 관리자 글 목록은 `1fr 120px 110px 156px` 고정 4열이라 태블릿·모바일에서 사용할 수 없습니다. 작은 화면에서는 카드 목록으로 전환합니다. +- 우측 하단 전화상담 고정 버튼은 모바일에서 콘텐츠·푸터를 가릴 수 있으므로 크기와 안전 여백을 조정합니다. + +## P1: 첫 배포 전에 보완 + +### 홈 + +- 히어로, 블로그 카드가 이미지 자리표시자 상태입니다. 실제 시공 이미지와 사용 권한, 대체 텍스트를 확보합니다. +- `20년`, `1,200+`, `24시간 내 견적 회신`, `정기 사후관리`는 대외 표시 가능한 근거와 정확한 서비스 조건을 확인합니다. +- 서비스 카드가 설명만 있고 상세 이동이 없습니다. 최소한 문의 유형이 미리 선택된 상담 화면으로 연결하거나 서비스 상세 섹션 앵커를 제공합니다. +- 모든 회사명 표기를 `(주)지티에스정보통신`으로 통일합니다. + +### 블로그 목록·상세 + +- 카드 전체가 `onclick`만 사용되어 키보드 접근과 새 탭 열기가 불가능합니다. 실제 `` 링크로 변경합니다. +- 숫자 파일명 대신 의미 있는 slug URL을 사용합니다. 예: `/blog/office-network-checklist`. +- 제목 외에 페이지별 description, canonical, Open Graph, 구조화 데이터, 대표 이미지 메타데이터가 필요합니다. +- 업로드 이미지마다 대체 텍스트, 캡션, 대표 이미지 선택 기능을 제공합니다. +- 관리자 글 편집에 초안/공개 상태가 없어 저장 즉시 노출됩니다. 초안과 공개 상태를 분리합니다. + +### 문의 + +- 지도는 자리표시자이며 실제 지도 사용 여부와 개인정보·외부 스크립트 정책을 정해야 합니다. 초기에는 지도 링크 버튼과 주소 텍스트만 제공하는 편이 가볍습니다. +- 성공 화면에는 접수번호, 접수 일시, 회신 방법, 예상 응답 시간을 표시합니다. +- 오류는 `alert`가 아니라 필드별 메시지와 요약 메시지로 제공하고 `aria-live`를 적용합니다. +- 전화번호 자동 형식화, 이메일 형식, 글자 수 제한, 서버 검증, CSRF, 허니팟 또는 속도 제한을 적용합니다. + +### 관리자 + +- 게시글 목록과 문의 목록은 시맨틱 테이블 또는 모바일 카드 구조로 구현합니다. +- 삭제는 즉시 영구 삭제보다 확인 단계와 복구 가능한 소프트 삭제를 권장합니다. +- 이미지 업로드는 확장자·MIME·용량·픽셀 크기 검증, 안전한 파일명, EXIF 제거, WebP 변환을 적용합니다. +- 답변이 고객에게 이메일로 전송되는지, 사이트에서 조회되는지, 단순 내부 기록인지 명확히 구분합니다. +- 회사 정보 변경은 입력 형식 검증과 변경 이력을 적용하고 관리자 비밀번호 변경 메뉴는 별도로 둡니다. + +## P2: 품질 및 운영 개선 + +- 19개 HTML 각각에 약 152KB의 동일한 스타일·데이터·로직이 반복됩니다. 공통 레이아웃, 컴포넌트, 데이터 접근 계층으로 분리합니다. +- 약 2MB인 동일 폰트 파일이 두 이름으로 중복되어 있습니다. 하나만 사용하고 필요한 경우 서브셋 또는 시스템 폰트 폴백을 검토합니다. +- 현재 화면은 전용 JavaScript 런타임에 의존합니다. 공개 콘텐츠는 서버 렌더링해 JavaScript 실패 시에도 제목·본문·연락처가 보여야 합니다. +- `design/assets/styles.css`와 `hero-network.png`는 현재 렌더링되는 프로토타입에서 사용되지 않습니다. 구현 시 사용 자산만 선별합니다. +- 접근성 QA에 키보드 이동, 포커스 표시, 200% 확대, 색상 대비, 이미지 대체 텍스트, 오류 안내를 포함합니다. +- 성능 QA에 모바일 LCP, 폰트 로딩, 이미지 지연 로딩, 캐시 헤더, 압축을 포함합니다. + +## 페이지별 구현 방향 + +| 화면 | 유지할 구성 | 수정할 구성 | +| --- | --- | --- | +| 홈 | 히어로 → 실적 → 서비스 → 강점 → 프로세스 → 블로그 → CTA 흐름 | 실제 이미지, 수치 검증, 서비스 이동 링크, 모바일 헤더 | +| 블로그 목록 | 카테고리와 카드 그리드 | 링크 시맨틱, 실제 썸네일, 빈 결과, 서버 페이지네이션 | +| 블로그 상세 | 제목·작성 정보·본문·문의 CTA | slug, SEO 메타, 이미지 alt/캡션, 이전·다음 글 | +| 1:1 문의 | 좌측 폼·우측 연락처 구조 | 개인정보 고지, 검증, 스팸 방어, 회신 방식 확정, 지도 대체 | +| 관리자 로그인 | 단일 로그인 화면 | 서버 인증, 시도 제한, 세션 만료, 비밀번호 재설정 | +| 글 관리 | 목록·작성·수정 흐름 | 공개 상태, slug, 대표 이미지, alt, 소프트 삭제 | +| 문의 관리 | 상태·상세·답변 흐름 | DB 저장, 답변 전달 방식, 개인정보 접근 기록·보관기간 | +| 회사 정보 | 한 화면 설정 | 검증, 변경 이력, 권한 분리 여부 | + +## 구현 기준 정보 구조 + +- 공개: `/`, `/blog`, `/blog/{slug}`, `/contact`, `/privacy` +- 관리자: `/admin/login`, `/admin/posts`, `/admin/inquiries`, `/admin/company` +- 관리자 URL은 디자인 파일명과 무관하게 모든 요청에서 서버 인증을 선행합니다. +- 공개 페이지는 PHP 서버 렌더링을 기본으로 하고 JavaScript는 메뉴, 필터, 폼 편의 기능에만 점진적으로 사용합니다. + +## 구현 전 사용자 확인 항목 + +- 문의 답변 방식: 전화·이메일 회신만 제공할지, 웹 답변 조회까지 제공할지 +- 표시할 대표번호, 지점번호, 이메일, 주소, 대표자, 사업자등록번호의 최종 값 +- `20년`, `1,200+`, `24시간`, A/S 문구의 대외 사용 가능 여부 +- 실제 시공 사진과 로고 원본의 사용 가능 여부 +- 블로그 9개 샘플 글을 초기 운영 콘텐츠로 사용할지 여부 diff --git a/docs/implementation-todo.md b/docs/implementation-todo.md new file mode 100644 index 0000000..76f7f2d --- /dev/null +++ b/docs/implementation-todo.md @@ -0,0 +1,174 @@ +# 구현 TODO + +최종 수정일: 2026-08-10 + +완료한 작업은 체크하고, 설정값이나 URL이 바뀌면 관련 문서도 함께 갱신합니다. + +## 0. 사전 조사 + +- [x] [Codex] 프로젝트 문서 상시 동기화 규칙을 `AGENTS.md`에 추가 +- [x] [Codex] 로컬 저장소 및 작업 대상 확인 +- [x] [Codex] `design/` 페이지 구성과 반복 구조 검토 +- [x] [Codex] 공개 화면과 관리자 화면의 모바일 문제 확인 +- [x] [Codex] 브라우저 저장소 및 클라이언트 인증 문제 확인 +- [x] [Codex] Cafe24 호스팅 상품, PHP, MariaDB, 보안 설정 확인 +- [x] [Codex] 도메인 이전 상태, 네임서버 위임, DNS 장애 상태 확인 +- [x] [사용자] 연결할 도메인을 `gtsit.co.kr`로 최종 확인 + +## 1. 임시 페이지 배포 + +- [x] [Codex] 외부 리소스 없는 `public/index.html` 제작 +- [x] [Codex] 데스크톱 화면 확인 +- [x] [Codex] 390px 모바일 화면 및 가로 넘침 확인 +- [x] [Codex] 로컬 검증 서버 종료 +- [x] [사용자] Cafe24 SSH 인증키 다운로드 +- [x] [Codex] 인증키를 `.secrets/`로 이동, 파일 권한 제한, Git 제외 +- [x] [사용자] Cafe24 `FTP/Shell 접속설정`에서 SSH key 로그인 재적용 +- [x] [사용자] `인증키 재발급` 없이 현재 등록된 SSH 인증키 다시 다운로드 +- [x] [Codex] 새 키 지문 확인, 권한 제한, `.secrets/` 보관 +- [x] [Codex] SSH/SFTP 접속 및 실제 웹 루트 확인 +- [x] [Codex] 서버 기존 파일 목록 확인 +- [x] [Codex] 기존 Cafe24 안내 파일 백업 +- [x] [Codex] 임시 페이지 업로드 +- [x] [Codex] `gts0201.mycafe24.com` HTTPS 200 및 파일 일치 확인 +- [x] [Codex] 실제 브라우저에서 임시 페이지와 가로 넘침 확인 +- [x] [Codex] 임시 페이지 상단 로고와 하단 저작권을 `(주)지티에스정보통신`으로 변경 및 재배포 +- [ ] [사용자] 다운로드 폴더의 인증키 복사본 정리 여부 결정 + +진행 메모: 2026-08-04 새로 다운로드한 키의 지문이 기존 키와 다른 것을 확인했고 새 키로 SSH 접속에 성공했습니다. 실제 홈은 `/home/hosting_users/gts0201`, 웹 루트는 `www`입니다. 기존 `hosting_index.html`은 `hosting_index.html.backup-20260804`로 보존했고, `public/index.html`을 `www/index.html`로 배포했습니다. 기본 도메인 HTTPS 200, 로컬·운영 파일 SHA-256 일치, 브라우저 렌더링 및 1280px 가로 넘침 없음까지 확인했습니다. + +## 2. 사용자 도메인 연결 + +- [x] [공동] Cafe24의 연결 대상 호스팅 `gts0201`과 도메인 `gtsit.co.kr` 재확인 +- [x] [Codex] 사용자 승인 후 호스팅에 도메인 연결 +- [x] [Codex] 루트 도메인 A 레코드 `112.175.85.162` 응답 확인 +- [x] [Codex] `www`가 루트 도메인을 거쳐 같은 서버로 연결되는지 확인 +- [x] [Codex] 로컬 및 Google Public DNS에서 기존 `SERVFAIL` 해소 확인 +- [ ] [Codex] Cloudflare DNS를 포함한 전체 전파 완료 확인 +- [x] [공동] 대표 주소를 `https://gtsit.co.kr`로 결정 +- [x] [Codex] Cafe24 대표 도메인을 `gtsit.co.kr`로 변경 +- [x] [Codex] HTTP 및 `www`를 대표 HTTPS 주소로 영구 리디렉션 +- [ ] [Codex] 기본 Cafe24 도메인의 운영 노출 정책 결정 및 적용 + +진행 메모: 2026-08-04 Cafe24 대표 도메인을 `gtsit.co.kr`로 변경했습니다. 변경 직후 외부 요청에서 `http://gtsit.co.kr`과 `https://www.gtsit.co.kr`은 `https://gtsit.co.kr`로 301 응답하고, 대표 HTTPS 주소는 200 응답하는 것을 재확인했습니다. Cafe24 자가진단은 같은 시각 서버 연결 및 SSL 오류를 표시했지만 실제 공개 접속과 TLS 검증은 정상이라 진단 표시 불일치로 기록합니다. + +## 3. HTTPS 설정 + +- [ ] [Codex] 도메인 DNS 전체 전파 확인 +- [x] [Codex] 도메인 연결 직후 HTTP 응답 및 배포 파일 일치 확인 +- [x] [Codex] Cafe24 SSL Basic 신청 가능 상태와 비용 확인 +- [x] [사용자] SSL 발급 및 설치 완료 +- [x] [Codex] 루트 및 `www` 인증서 적용 확인 +- [x] [Codex] HTTP에서 HTTPS로 301 리디렉션 +- [x] [Codex] `www`에서 루트 HTTPS 주소로 301 리디렉션 +- [x] [Codex] 혼합 콘텐츠와 인증서 체인 QA + +진행 메모: 2026-08-04 Let’s Encrypt 인증서가 루트와 `www`에 적용됐습니다. 인증서 유효기간은 2026-08-04~2026-11-02이며, HTTP와 `www`는 `https://gtsit.co.kr`로 301 리디렉션됩니다. + +## 4. MariaDB 설정 + +- [x] [Codex] DB 주소 `localhost`, 계정 `gts0201`, 포트 `3306`, MariaDB `10.6.5` 확인 +- [x] [Codex] PHP 8.4의 PDO MySQL 및 mysqli 확장 사용 가능 여부 확인 +- [x] [Codex] PHP 기본 문자셋 UTF-8 및 시간대 `Asia/Seoul` 확인 +- [x] [사용자] 기존 DB 비밀번호를 로컬 비밀 설정에 입력 +- [x] [사용자] DB 비밀번호를 `.secrets/db.env`에 직접 저장 +- [x] [Codex] DB 접속 설정을 서버 웹 루트 밖에 배치하고 권한 `600` 적용 + +- [x] [Codex] DB 비밀번호와 설정 파일의 Git 제외 확인 +- [x] [Codex] 비밀번호 인증 연결 테스트 및 임시 진단 파일 제거 +- [x] [Codex] 빈 DB 기본값을 `utf8mb4`와 `utf8mb4_unicode_ci`로 변경 +- [x] [Codex] `database/schema.sql` 초안 작성 +- [x] [Codex] 관리자, 게시글, 이미지, 문의, 사이트 설정 테이블 생성 +- [x] [Codex] 초기 관리자 계정의 안전한 생성 절차 마련 +- [x] [사용자] 초기 관리자 아이디 `gts0201` 확정 +- [ ] [사용자] 1회용 등록 URL에서 관리자 비밀번호 직접 설정 +- [ ] [Codex] 백업 및 복구 절차 검증 + +진행 메모: 2026-08-04 웹 진단으로 PHP 8.4.21p1, PDO 드라이버 `mysql`, `mysqli`, `mbstring`, `openssl`, `fileinfo`, 업로드 100MB, 메모리 256MB를 확인했습니다. MariaDB 10.6.17 비밀번호 인증에 성공했고, 테이블 0개인 빈 DB의 기본값을 `utf8mb4`와 `utf8mb4_unicode_ci`로 변경했습니다. 서버 접속 설정은 웹 루트 밖의 `.gtsit-db.env`에 권한 600으로 저장했으며 모든 임시 웹 파일은 삭제했습니다. + +관리자 등록 메모: 초기 아이디는 `gts0201`입니다. 비밀번호는 채팅이나 저장소에 남기지 않고 24시간 유효한 1회용 URL에서 사용자가 직접 입력합니다. 계정 생성 후 서버의 등록 토큰 파일은 자동 삭제됩니다. + +## 5. 디자인 구현 전 확정 + +- [x] [Codex] 공개·관리자 전체 디자인 구조 검토 +- [x] [Codex] 접수번호와 비밀번호를 사용한 웹 답변 조회 흐름 구현 +- [ ] [사용자] 회사 정보와 대외 표시 실적 수치 최종 확인 +- [x] [사용자] 대표 이메일 `gts0201@naver.com` 확인 +- [x] [사용자] 주소 `서울시 송파구 정의로7길 13, B동 오피스 1020호` 확인 +- [x] [사용자] 공개·관리자 전체 회사명을 `(주)지티에스정보통신`으로 통일 +- [x] [Codex] `GTS` 글자 기반 워드마크 로고 이미지 생성 및 공개·관리자·임시 페이지 적용 +- [x] [Codex] HTML 템플릿의 네트워크 시공 이미지를 최적화해 메인 비주얼에 적용 +- [x] [Codex] 기존 사이트 백업의 실제 시공 이미지 4장으로 메인 자동 슬라이드 구현 +- [x] [Codex] 기존 사이트의 LAN·무선·키폰·CCTV 글을 재구성한 공개 게시글 5건 등록 +- [x] [사용자] 푸터 전화번호 연결 제거 및 플로팅 전화상담 아이콘 적용 +- [x] [Codex] 네이버 지도 API 연동 방식 및 공식 등록 요건 확인 +- [x] [사용자] NAVER Cloud Maps Application 생성, Dynamic Map 선택, 웹 URL `http://gtsit.co.kr` 등록 +- [x] [사용자] Maps Client ID 전달·등록, Client Secret은 전달하지 않음 +- [x] [Codex] 오시는 길 지도·회사 마커·네이버 지도 열기 fallback 구현 +- [x] [Codex] 지도 SDK 요소 높이 안정화 및 사용자 정의 전체화면 컨트롤 적용 +- [x] [사용자] 기존 사이트 백업의 실제 시공 이미지 원본 전달 +- [ ] [사용자] 최신 시공 이미지 추가 전달 +- [x] [Codex] 관리자 인증·세션·접근 통제 방식 확정 +- [x] [Codex] 모바일 헤더와 관리자 모바일 목록 구조 설계 +- [x] [Codex] 2026 개인정보보호위원회 작성지침 기반 개인정보 수집·이용 고지 및 개인정보처리방침 확정 +- [x] [Codex] 공통 PHP 템플릿과 MariaDB 기반 구현 구조 확정 + +## 6. 공통 마크업 및 기반 구축 + +- [x] [Codex] 디자인 토큰과 공통 CSS 정리 +- [x] [Codex] 공통 헤더, 내비게이션, 푸터 템플릿화 +- [x] [Codex] 모바일 공개 헤더 구조 구현 +- [x] [Codex] 관리자 목록 모바일 카드 구조 구현 +- [x] [Codex] 공통 버튼, 입력 필드, 표, 상태 UI 정리 +- [x] [Codex] 접근 가능한 포커스, 오류, 빈 상태 정의 +- [x] [Codex] PHP 라우팅 및 오류 페이지 구성 + +## 7. 공개 페이지 구현 + +- [x] [Codex] 홈 구현 +- [x] [Codex] 게시글 목록 및 페이지네이션 구현 +- [x] [Codex] 게시글 상세 동적 템플릿 구현 +- [x] [Codex] 주제별 공개 게시글과 대표 이미지 데이터 반영 +- [x] [Codex] 대표 이미지 없는 게시글의 브랜드형 임시 썸네일 적용 +- [x] [Codex] 메인 서비스 카드의 네트워크·키폰·CCTV·유지보수 고정 아이콘 구현 +- [x] [Codex] 메인 서비스 카드 아이콘 테스트 경로 배포 및 시각 QA +- [x] [Codex] 문의 작성 및 서버 검증 구현 +- [x] [Codex] 문의 이메일 도메인 선택·직접입력 전환 적용 +- [x] [Codex] 단일 연락처 필드의 휴대전화·지역번호·대표번호 자동 형식 적용 +- [x] [Codex] 문의 입력 안내 제거·placeholder 대비·드롭다운 꺾쇠 여백 조정 +- [x] [Codex] 문의 조회 및 본인 확인 구현 +- [x] [Codex] 숫자 4자리 조회 PIN과 접수번호별 5회 실패·15분 잠금 적용 +- [x] [Codex] 개인정보 수집·이용 요약 고지와 최종 개인정보처리방침 적용 +- [x] [Codex] 공개 페이지 반응형 QA + +## 8. 관리자 구현 + +- [x] [Codex] 서버 세션 기반 관리자 로그인 구현 +- [x] [Codex] 클라이언트 코드의 기존 관리자 인증값 제거 +- [x] [Codex] 대시보드 구현 +- [x] [Codex] 게시글 목록, 작성, 수정, 소프트 삭제 구현 +- [x] [Codex] 이미지 업로드 검증 구현 +- [x] [Codex] 문의 목록, 상세, 처리 상태 및 공개 답변 구현 +- [x] [Codex] 회사 및 사이트 설정 구현 +- [x] [Codex] CSRF, 세션 만료, 로그인 시도 제한, 권한 검증 적용 + +## 9. 운영 설정 및 출시 + +- [x] [Codex] 운영 루트를 유지한 채 `/site-preview-7c2a91f4` 테스트 라우팅 배포 +- [x] [Codex] 테스트 경로 검색엔진 차단 및 세션 쿠키 경로 격리 +- [ ] [Codex] 만료 문의 파기 명령의 정기 실행 일정 등록 +- [ ] [공동] ModSecurity와 실제 기능 호환성 QA +- [ ] [공동] PHP 안티웹쉘 활성화 여부 결정 및 QA +- [x] [Codex] 업로드 디렉터리 PHP 실행 차단 설정 작성 +- [ ] [사용자] 하드 용량 및 트래픽 알림 수신처 결정 +- [ ] [공동] 용량 및 트래픽 알림 활성화 +- [ ] [공동] 검색 봇 차단 정책과 공개 시점 결정 +- [ ] [사용자] 대표 메일 서비스 결정 +- [ ] [공동] Cafe24 백업 외 별도 외부 백업 위치 결정 +- [ ] [Codex] 운영 QA 전체 실행 +- [ ] [공동] 임시 페이지를 본 사이트로 전환 +- [ ] [Codex] 출시 후 DNS, HTTPS, 오류 로그, 문의 흐름 재확인 + +진행 메모: 2026-08-04 기존 임시 안내 페이지는 `https://gtsit.co.kr/`에 유지하고 본 사이트 테스트 빌드를 `https://gtsit.co.kr/site-preview-7c2a91f4/`에 배포했습니다. 테스트 경로는 `X-Robots-Tag`와 HTML robots 메타로 색인을 차단하며, 세션 쿠키 경로도 테스트 경로로 제한합니다. + +지도 연동 메모: 2026-08-05 Client ID `0xuekem2qn`을 `ncpKeyId`로 등록했습니다. 고정된 회사 위치는 주소 변환 API를 매번 호출하지 않고 확인된 위·경도로 표시하며, `Dynamic Map`만으로 회사 마커를 렌더링합니다. 지도 SDK 로드·인증 실패 시에도 주소와 네이버 지도 검색 링크를 유지합니다. diff --git a/docs/legacy-content-migration.md b/docs/legacy-content-migration.md new file mode 100644 index 0000000..2dd71c6 --- /dev/null +++ b/docs/legacy-content-migration.md @@ -0,0 +1,41 @@ +# 기존 사이트 콘텐츠 이관 기록 + +최종 수정일: 2026-08-05 + +## 1. 원본 범위 + +- 원본 폴더: `/Users/feel_home_m5/workspace/MY/html/` +- 형태: GnuBoard 기반 기존 사이트 백업 +- 주요 텍스트: `network1.html`, `network2.html`, `network3.html`, `keyphone1.html`, `cctv.html` +- 주요 사진: `data/file/case/`의 원본 JPG +- 제외 대상: 관리자 UI 이미지, 제품 썸네일, CAPTCHA·캐시·테마 이미지, 개인정보 가능성이 있는 로그·문의 데이터 + +## 2. 메인 슬라이드 이미지 + +| 적용 파일 | 내용 | 원본 | +| --- | --- | --- | +| `field/keyphone-network-rack.jpg` | 키폰·네트워크 통합 장비 랙 | `1868270649_exLJIMtX_...JPG` | +| `field/lan-cabling-rack.jpg` | 패치패널·스위치 배선 정리 | `1868270649_9aEG5TN2_...JPG` | +| `field/cctv-control-rack.jpg` | CCTV 녹화장치·관제 랙 | `1868270649_0caniNb8_...JPG` | +| `field/outdoor-wireless-link.jpg` | 옥외 무선 통신 장비 | `1868270649_Z5yW9YJ8_...JPG` | + +모든 이미지는 EXIF 방향을 반영한 1200×900 JPEG로 최적화했습니다. 특정 고객명이나 장소를 식별할 수 있는 설명은 사용하지 않습니다. + +## 3. 공개 게시글 + +| 분류 | 제목 | slug | +| --- | --- | --- | +| 네트워크 | 사무실 LAN 구축, 구조와 배선 설계부터 확인하세요 | `office-lan-structure-cabling-guide` | +| 네트워크 | 옥외 무선 통신 구간 설치 시 확인할 사항 | `outdoor-wireless-link-installation-guide` | +| 키폰시스템 | 키폰시스템 주장치 선택, 증설 용량이 중요합니다 | `keyphone-system-capacity-selection` | +| CCTV | CCTV 녹화장치 구성 시 꼭 확인할 기능 | `cctv-recorder-essential-features` | +| 시공사례 | 통신실 랙 배선 정리가 유지보수를 좌우하는 이유 | `network-rack-cabling-maintenance-case` | + +원문의 핵심 개념만 사용하고 Windows Server 2003, WEP, FDD·CD-RW 같은 오래된 제품·기술 표현은 현재 고객 안내에 맞게 제외하거나 일반화했습니다. 사진만으로 확인할 수 없는 고객, 현장 규모, 공사 결과는 추정하지 않았습니다. + +## 4. 반영 방식 + +- 데이터 파일: `database/seed-legacy-content.sql` +- slug 중복 시 내용 갱신, 삭제 상태 복구, 대표 이미지 재연결 +- 기존 게시글과 관리자 초안 유지 +- 공개 이미지 경로: `/assets/images/field/` diff --git a/docs/qa-list.md b/docs/qa-list.md new file mode 100644 index 0000000..ab6a48d --- /dev/null +++ b/docs/qa-list.md @@ -0,0 +1,253 @@ +# QA 체크리스트 + +최종 수정일: 2026-08-10 + +기능 구현 시 해당 섹션을 복사하거나 항목을 추가해 계속 확장합니다. 민감정보가 포함된 화면 캡처와 로그는 저장소에 넣지 않습니다. + +## 실행 기록 템플릿 + +| 항목 | 기록 | +| --- | --- | +| 실행일 | YYYY-MM-DD | +| 담당자 | | +| 환경 | 로컬 / Cafe24 기본 도메인 / 운영 도메인 | +| URL | | +| 브라우저·기기 | | +| 배포 버전 | | +| 결과 | 통과 / 조건부 통과 / 실패 | +| 증빙 | 안전한 스크린샷 또는 이슈 링크 | + +## 1. 임시 페이지 + +- [x] 로컬에서 HTML 정상 표시 +- [x] 외부 CSS, 폰트, 이미지 의존성 없음 +- [x] 390px 모바일에서 가로 스크롤 없음 +- [x] 전화 링크의 번호와 표시 문구 확인 +- [x] 검색엔진 `noindex, nofollow` 적용 +- [x] Cafe24 기본 도메인에서 HTTPS 200 응답 +- [x] 운영 도메인에서 로컬 원본과 동일한 콘텐츠 표시 +- [x] Cafe24 기본 도메인 HTTPS에서 보안 경고 없음 +- [x] 로고와 저작권에 `(주)지티에스정보통신` 표시 +- [ ] 모바일 실기기에서 전화 링크 동작 + +## 2. 배포 및 서버 + +- [x] 현재 등록된 SSH 인증키를 다시 내려받아 로컬 키 지문 비교 +- [x] 서버에 등록된 키로 SSH 로그인 성공 +- [x] 실제 웹 루트와 업로드 경로 확인 +- [x] 기존 파일 백업 후 배포 +- [ ] 디렉터리 목록 노출 차단 +- [ ] 불필요한 설정, 백업, 로그 파일의 웹 접근 차단 +- [ ] 404 및 500 오류 응답 확인 +- [x] 임시 `index.html` 파일 권한 `640` 적용 +- [x] 웹 루트에 `Options -Indexes` 적용 +- [ ] 배포 후 오류 로그에 신규 치명 오류 없음 + +테스트 배포 기록: 2026-08-04 `https://gtsit.co.kr/site-preview-7c2a91f4/`에 본 사이트를 별도 라우팅했습니다. 홈·블로그·문의·개인정보·문의 조회·관리자 로그인은 200, 비로그인 관리자 경로는 테스트 경로의 로그인으로 302, 없는 경로는 404를 반환했습니다. CSS·JavaScript 에셋 4종은 모두 200이며 기존 운영 루트 임시 페이지도 200과 기존 문구를 유지합니다. + +회사 정보 반영 기록: 2026-08-04 대표 이메일과 주소를 `site_settings` 및 애플리케이션 기본값에 반영했습니다. 테스트 문의 페이지에서 새 주소, 푸터에서 새 이메일·주소가 표시되고 이전 값이 남아 있지 않은 것을 확인했습니다. + +회사명 통일 기록: 2026-08-04 공개 헤더·푸터·메타데이터, 관리자 콘솔, 게시글 작성자 기본값과 기존 초안 9건, 운영 루트 임시 페이지를 `(주)지티에스정보통신`으로 통일했습니다. 실제 응답 3개 화면에서 이전 표기 0건, 새 표기 16건을 확인했고 360·390·1440px에서 가로 넘침이 없었습니다. + +푸터·플로팅 버튼 기록: 2026-08-04 푸터의 전화번호를 일반 텍스트로 변경해 `tel:` 링크 0건을 확인했습니다. 플로팅 전화상담 버튼은 전화기 SVG 아이콘, 대표번호 `tel:` 링크와 접근성 이름을 유지하며 실제 Chrome 렌더링을 확인했습니다. + +브랜드 로고 적용 기록: 2026-08-04 도식형 심볼을 `GTS` 글자 기반 워드마크로 교체해 `public/assets/images/company-logo-v3.png`로 적용했습니다. 공개 헤더·푸터, 관리자 헤더·로그인·초기 설정, 운영 임시 페이지에서 512×237 투명 PNG 로드를 확인했습니다. 테스트 경로 1280px와 390px에서 헤더 로고를 64×29.6px로 표시했으며 회사명·메뉴 겹침과 가로 넘침이 없었습니다. + +메인 이미지 적용 기록: 2026-08-05 HTML 템플릿 자산 `design/assets/hero-network.png`를 1200×675, 151KB JPEG로 최적화해 메인 비주얼에 적용했습니다. CSS 캐시 갱신 버전을 추가했고 테스트 사이트에서 1280px·390px 모두 이미지 로드, `cover` 크롭, 가로 넘침 없음을 확인했습니다. 원본은 템플릿 제공 이미지이므로 실제 회사 시공 사진이 확보되면 동일 영역에서 교체합니다. + +메인 슬라이드·콘텐츠 이관 기록: 2026-08-05 기존 사이트 백업의 실제 시공 사진 4장을 1200×900 JPEG로 최적화해 5초 자동 슬라이드로 교체했습니다. 1280px에서 첫 번째에서 두 번째 사진으로 자동 전환되고, 390px에서 컨트롤 한 줄 표시와 가로 넘침 없음을 확인했습니다. LAN·옥외 무선·키폰·CCTV·통신 랙 관리 게시글 5건을 공개해 기존 공개 글을 포함한 카드 6건, 대표 이미지, 상세 본문 4개 문단과 페이지 메타데이터를 확인했습니다. + +네이버 지도 연동 기록: 2026-08-05 `https://gtsit.co.kr/site-preview-7c2a91f4/contact`에서 Client ID와 허용 URL 인증, 지도 `ready` 상태, `(주)지티에스정보통신` 마커, 네이버 지도 검색 링크를 확인했습니다. 주소 변환 API는 활성화하지 않고 확인된 고정 좌표로 `Dynamic Map`만 사용합니다. Client Secret은 전달받거나 저장하지 않았으며, SDK 실패 상태에서도 주소와 외부 지도 링크가 유지됩니다. + +지도 표시·전체화면 수정 기록: 2026-08-05 NAVER SDK가 지도 요소의 위치 속성을 덮어써 내부 높이가 0px이 된 원인을 수정했습니다. 일반 상태에서 프레임 300px·지도 298px·타일 이미지 27개·마커 1개를 확인했습니다. 사용자 정의 전체화면 아이콘으로 720px 확장 후 298px 복귀와 마커 유지를 검증했고, 390px에서도 지도 298px와 가로 넘침 없음을 확인했습니다. 정상 상태의 외부 텍스트 링크는 숨기고 실패 시 fallback으로 유지합니다. + +블로그 임시 썸네일 기록: 2026-08-05 공개 글 6건 중 실제 대표 이미지 5건은 기존 사진을 유지하고, 이미지가 없는 1건에는 `GTS` 브랜드 플레이스홀더가 표시되는 것을 확인했습니다. 1280px에서 카드 행 높이 정렬과 가로 넘침 없음, 390px에서 카드 358px·플레이스홀더 356×199px 및 가로 넘침 없음을 확인했습니다. + +문의 입력 편의 기록: 2026-08-05 이메일 아이디 `11111`과 `naver.com` 선택 시 전송용 값 `11111@naver.com`, `직접입력` 선택 시 전체 주소 `hello@company.co.kr`이 유지되는 것을 확인했습니다. 연락처 숫자 `01011111111`은 `010-1111-1111`로 자동 변환됐습니다. 실제 문의는 생성하지 않았으며 390px에서 이메일 필드 312px와 가로 넘침 없음을 확인했습니다. + +문의 입력 시각 보정 기록: 2026-08-05 연락처 자동 하이픈 안내 문구와 연결된 `aria-describedby`를 제거했습니다. 연락처 placeholder는 `14px`·`rgb(161, 161, 170)`, 이메일 아이디 placeholder는 `13px`·`rgb(176, 179, 186)`로 확인했습니다. 이메일 도메인·문의 유형 드롭다운은 우측 여백 `14px`, 내부 우측 여백 `42px`로 조정했으며 390px 화면에서 이메일 아이디 `155px`, 도메인 `132px`, 가로 넘침 없음을 확인했습니다. + +연락처·조회 PIN 기록: 2026-08-05 단일 연락처 필드에서 `010-1234-5678`, `02-123-4567`, `02-1234-5678`, `031-123-4567`, `031-1234-5678`, `1588-1234`, `070-1234-5678`, `0507-1234-5678` 자동 형식을 확인했습니다. 문자와 기호가 섞인 조회 PIN 입력은 숫자 4자리만 남았으며, 실제 QA 문의를 생성해 올바른 PIN 조회 성공과 잘못된 PIN 5회 후 15분 잠금을 확인한 뒤 테스트 문의를 완전 삭제했습니다. + +개인정보처리방침 기록: 2026-08-05 개인정보보호위원회 「개인정보 처리방침 작성지침(2026.4.)」과 개인정보 포털의 수집·이용 동의 양식을 기준으로 처리 목적·항목·법적 근거·보유기간·제3자 제공·Cafe24 위탁·파기·권리 행사·안전조치·세션 쿠키·담당부서·권익침해 구제·시행일을 반영했습니다. 문의 폼에도 목적·필수/선택 항목·보유기간·동의 거부 불이익을 직접 표시했습니다. + +서비스 카드 아이콘 기록: 2026-08-10 네트워크·키폰·CCTV·유지보수 카드에 항목별 인라인 SVG 아이콘을 고정 UI 자산으로 배포했습니다. 테스트 경로에서 아이콘 4개와 각 SVG 존재, 48×48px 컨테이너와 26×26px 아이콘, 항목별 배경·선 색상, 중앙 정렬, CSS 버전 `20260805-11`, 1280px 화면의 가로 넘침 없음을 확인했습니다. 운영 루트 임시 페이지는 기존 상태로 HTTP 200 응답을 유지합니다. + +파기 명령 기록: 2026-08-05 `bin/purge-inquiries.php`를 Cafe24 PHP 8.4와 MariaDB에서 실행해 만료 대상 0건으로 정상 종료했습니다. 현재 뉴아우토반 일반형은 Cafe24 공식 안내상 cron 미지원 상품이고 서버에도 `crontab` 명령이 없어, 정기 실행 등록은 외부 스케줄러 또는 호스팅 상품 변경 시 진행합니다. + +진행 기록: 2026-08-04 새 키로 SSH 로그인 성공. 웹 루트 `/home/hosting_users/gts0201/www`, 기존 안내 파일 백업, 임시 페이지 배포 완료. `https://gts0201.mycafe24.com` HTTPS 200, 운영·로컬 파일 SHA-256 일치, 브라우저 1280px에서 가로 넘침 없음 확인. + +## 3. 도메인 및 DNS + +- [x] 연결 도메인 `gtsit.co.kr` 철자와 소유 계정 확인 +- [x] 루트 도메인의 A 레코드가 호스팅 서버 `112.175.85.162`를 가리킴 +- [x] `www` 도메인이 루트 도메인을 거쳐 같은 서버를 가리킴 +- [x] 로컬 및 Google Public DNS에서 `SERVFAIL` 해소 +- [ ] 여러 공개 DNS에서 결과 일치 +- [x] Cafe24 대표 도메인이 `gtsit.co.kr`로 표시됨 +- [x] HTTP와 `www`가 `https://gtsit.co.kr`로 301 리디렉션 +- [ ] 기존 메일 사용 시 MX 및 TXT 레코드 보존 확인 + +진단 참고: 2026-08-04 Cafe24 자가진단은 서버 연결 및 SSL 오류를 표시했으나, 외부 HTTP/HTTPS 요청과 인증서 검증은 정상입니다. Cafe24 진단 결과만 근거로 장애 처리하지 않고 실제 응답을 함께 확인합니다. + +## 4. HTTPS + +- [x] SSL Basic 신청 가능 도메인과 최초 1회 설치비 11,000원 확인 +- [x] 루트와 `www` 인증서의 도메인 일치 +- [x] 인증서 유효기간 2026-08-04~2026-11-02 확인 +- [x] HTTP 요청이 HTTPS로 한 번만 리디렉션 +- [x] `www` 요청이 대표 루트 주소로 한 번만 리디렉션 +- [x] 임시 페이지 혼합 콘텐츠 없음 +- [x] 루트와 `www`에서 인증서 경고 없이 HTTPS 200 응답 +- [ ] 세션 쿠키에 `Secure`, `HttpOnly`, 적절한 `SameSite` 적용 + +## 5. 데이터베이스 + +- [x] MariaDB 10.6.5 클라이언트와 `localhost:3306` 접속정보 확인 +- [x] PHP PDO MySQL 및 mysqli 확장 확인 + +- [x] PHP UTF-8 및 `Asia/Seoul` 기본값 확인 +- [x] 비밀번호 없는 DB 접근 차단 확인 +- [x] 비밀번호 인증 DB 연결 확인 +- [x] DB 기본 문자셋 `utf8mb4` 및 `utf8mb4_unicode_ci` 적용 +- [x] DB 설정 파일의 웹 루트 외부 배치와 권한 `600` 확인 +- [x] 임시 DB 진단 및 설정 파일 제거 확인 +- [ ] `utf8mb4` 한글 및 이모지 저장·조회 +- [ ] 서버 시간과 화면 표시 시간 일치 +- [ ] 모든 쿼리에 파라미터 바인딩 적용 +- [ ] DB 오류에 접속정보 또는 SQL 원문 미노출 +- [ ] 외부 MySQL 접속 차단 유지 +- [ ] 백업 생성 및 복원 테스트 +- [ ] 삭제 또는 익명화 정책 동작 확인 + +진행 기록: 2026-08-04 `admins`, `posts`, `post_images`, `inquiries`, `site_settings` 테이블 생성 및 전체 테이블 `utf8mb4_unicode_ci` 적용 확인. + +## 6. 디자인 구현 필수 QA + +- [x] 비로그인 상태에서 관리자 및 글 관리 URL이 로그인 화면으로 302 이동 +- [x] 관리자 비밀번호 및 인증 판정 로직이 HTML·JavaScript에 노출되지 않음 +- [x] 문의와 게시글이 브라우저 저장소가 아닌 MariaDB에 저장됨 +- [x] 360px 이상 공개 화면에서 가로 스크롤 없음 +- [ ] 모바일 관리자 목록이 카드 또는 접근 가능한 표 형태로 표시됨 +- [x] 문의 필수값, 형식, 글자 수, 동의 여부의 서버 검증 +- [x] 개인정보 수집 항목·목적·보유기간·거부 안내 표시 +- [ ] 블로그 카드와 주요 탐색 요소를 키보드로 이용 가능 +- [x] 페이지별 title, description, canonical, Open Graph 적용 +- [x] 업로드 이미지 형식·용량·크기 검증 및 대체 텍스트 제공 + +## 7. 공통 UI 및 접근성 + +- [x] 360px, 390px, 768px, 1024px, 1440px 레이아웃 확인 +- [x] 가로 스크롤과 콘텐츠 잘림 없음 +- [ ] 키보드만으로 모든 조작 가능 +- [ ] 포커스 표시가 명확함 +- [x] 제목 구조가 논리적이고 페이지당 `h1`이 명확함 +- [x] 입력 요소에 연결된 레이블 존재 +- [ ] 오류 메시지를 색상 외의 방법으로 전달 +- [ ] 이미지 대체 텍스트 적절성 확인 +- [ ] 모달 사용 시 포커스 이동과 복귀 확인 +- [ ] 확대 200%에서도 주요 기능 사용 가능 + +## 8. 공개 페이지 + +- [x] 홈의 주요 링크와 CTA 동작 +- [x] 홈 서비스 카드 4종의 항목별 고정 아이콘 표시 +- [x] 대표 이미지 없는 게시글 카드에 브랜드형 임시 썸네일 표시 +- [x] 문의 이메일 도메인 선택·직접입력 전환 동작 +- [x] 문의 연락처의 휴대전화·지역번호·대표번호별 자동 형식 적용 +- [x] 문의 입력 placeholder 대비와 드롭다운 꺾쇠 여백 +- [ ] 게시글 목록의 정렬과 페이지네이션 동작 +- [ ] 게시글 상세의 줄바꿈, 이미지, 이전·다음 글 동작 +- [x] 존재하지 않는 게시글에 404 응답 +- [x] 문의 필수값과 형식 검증 +- [x] 중복 제출 방지 +- [ ] 문의 조회번호 발급과 안내 +- [ ] 올바른 조회정보로 문의 확인 +- [ ] 틀린 조회정보로 개인정보 노출 없음 +- [ ] 성공, 오류, 빈 상태 문구 확인 + +## 9. 관리자 + +- [x] 로그인 전 관리자 URL 접근 차단 +- [ ] 올바른 계정 로그인과 로그아웃 동작 +- [x] 관리자 초기 등록 URL의 토큰 없는 접근 404, 유효 토큰 교환 302, 등록 폼 200 +- [x] 관리자 초기 등록의 약한 비밀번호 422 거부 +- [ ] 반복 로그인 실패 제한 +- [ ] 세션 만료 후 재인증 요구 +- [ ] 게시글 작성, 수정, 삭제, 공개 상태 동작 +- [ ] 관리자 목록의 검색, 필터, 페이지네이션 동작 +- [ ] 문의 목록, 상세, 처리 상태 변경 동작 +- [ ] 모바일 표와 조작 버튼의 잘림 없음 +- [x] 관리자 변경 작업에 CSRF 방어 적용 +- [x] 일반 사용자가 관리자 경로를 호출할 수 없음 + +진행 기록: 2026-08-04 공식 PHP 8.4 컨테이너와 Cafe24 PHP 8.4에서 전체 PHP 문법 통과. 로컬 주요 경로 `/`, `/blog`, `/contact`, `/privacy`, `/inquiry/lookup`, `/admin/login` 200, 비로그인 `/admin`, `/admin/posts` 302, 없는 경로 404 확인. 문의 CSRF 누락 403 및 빈 필수값 제출 422 확인. Cafe24가 비표준 419를 500으로 변환하는 것을 확인해 표준 403으로 조정했습니다. + +추가 기록: 2026-08-04 시스템 Chrome으로 공개 주요 6개 경로를 360·390·768·1024·1440px에서 재검수했습니다. 모든 조합에서 HTTP 200, 가로 넘침 없음, 페이지당 `h1` 1개, 누락된 폼 레이블 0개를 확인했습니다. 모바일 문의 화면에서 고정 전화 버튼이 입력 영역을 가리던 문제는 640px 이하에서 버튼을 숨기도록 수정 후 재검수를 통과했습니다. + +## 10. 파일 업로드 + +- [x] 허용 확장자와 MIME만 업로드 가능 +- [x] 최대 용량 초과 차단 및 안내 +- [x] 실행 가능한 파일 업로드 차단 +- [x] 서버에서 안전한 파일명으로 변경 +- [x] 동일 이름 파일 충돌 없음 +- [ ] 이미지 메타데이터와 방향 처리 확인 +- [ ] 삭제된 게시글의 파일 정리 정책 확인 +- [x] 업로드 경로에서 PHP 실행 불가 확인 + +진행 기록: 2026-08-04 Cafe24 테스트 경로의 업로드 폴더에 무해한 PHP 검사 파일을 잠시 배치해 HTTP 403과 미실행을 확인한 뒤 즉시 삭제했습니다. + +## 11. 보안 및 개인정보 + +- [ ] HTML과 스크립트 입력의 XSS 방어 +- [ ] SQL 삽입 입력 방어 +- [x] 관리자 비밀번호와 문의 조회 PIN 해시 저장 +- [x] HTML, JavaScript, 로그에 비밀번호 없음 +- [x] 오류 화면에 서버 경로와 스택 정보 없음 +- [x] 개인정보 동의 내용과 수집 항목 일치 +- [x] 개인정보 보관기간·종료일 기록·완전 삭제 명령 구현 +- [ ] 만료 문의 완전 삭제 명령의 정기 실행 등록 +- [ ] 보안 헤더 적용 여부 확인 +- [ ] ModSecurity와 UploadGuard 오탐 없음 + +## 12. 검색, 성능 및 운영 + +- [ ] 임시 페이지 종료 후 `noindex` 제거 여부 확인 +- [x] 테스트 경로 `X-Robots-Tag`와 robots 메타 색인 차단 +- [ ] 페이지별 고유 제목과 설명 적용 +- [ ] canonical과 대표 도메인 일치 +- [ ] `robots.txt`와 사이트맵 정책 확인 +- [ ] 이미지 크기와 지연 로딩 적절성 확인 +- [ ] 모바일 저속 환경에서 주요 콘텐츠 표시 +- [ ] 용량 및 트래픽 알림 동작 확인 +- [ ] 일일 백업과 외부 백업 성공 확인 +- [ ] 장애 시 임시 페이지로 되돌리는 절차 확인 + +## 13. 네이버 지도 + +- [x] Dynamic Map Client ID 및 허용 웹 서비스 URL 일치 +- [x] 회사 주소 좌표와 마커 위치 일치 +- [x] 지도 SDK 적용 후 실제 지도 요소 높이와 타일 표시 +- [x] 사용자 정의 전체화면 아이콘 열기·종료 및 지도 크기 복구 +- [ ] 데스크톱·모바일 지도 확대·축소 및 스크롤 충돌 없음 +- [x] 지도 API 로드 실패 시 주소와 네이버 지도 링크 유지 +- [x] Client Secret이 HTML·JavaScript·저장소에 포함되지 않음 +- [ ] API 이용 한도와 임계치 알림 설정 + +## 이슈 기록 템플릿 + +```md +### QA-000 제목 + +- 환경: +- URL: +- 재현 절차: + 1. + 2. +- 기대 결과: +- 실제 결과: +- 심각도: Blocker / Critical / Major / Minor +- 증빙: +- 상태: Open / Fixed / Retest / Closed +``` diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..ab7f4e6 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,14 @@ +Options -Indexes + +DirectoryIndex index.php index.html + +RewriteEngine On + +RewriteCond %{HTTP_HOST} ^(?:www\.)?gtsit\.co\.kr$ [NC] +RewriteCond %{HTTPS} !=on [OR] +RewriteCond %{HTTP_HOST} ^www\.gtsit\.co\.kr$ [NC] +RewriteRule ^ https://gtsit.co.kr%{REQUEST_URI} [R=301,L] + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [L,QSA] diff --git a/public/assets/css/admin.css b/public/assets/css/admin.css new file mode 100644 index 0000000..9de6d5b --- /dev/null +++ b/public/assets/css/admin.css @@ -0,0 +1,5 @@ +.admin-body{background:#f8fafc}.admin-header{background:#fff;border-bottom:1px solid var(--border)}.admin-header-inner{width:min(100% - 48px,1100px);height:64px;margin:auto;display:flex;align-items:center;justify-content:space-between;gap:20px}.admin-brand{display:flex;align-items:center;gap:10px;text-decoration:none}.admin-brand .brand-mark{width:38px;height:38px}.admin-account{display:flex;align-items:center;gap:14px;font-size:13px}.admin-account form{margin:0}.admin-account form button{border:0;background:none;cursor:pointer;color:var(--secondary)}.admin-nav{background:#fff;border-bottom:1px solid var(--border)}.admin-nav>div{width:min(100% - 48px,1100px);margin:auto;display:flex;gap:6px;overflow:auto}.admin-nav a{padding:14px 12px;text-decoration:none;color:var(--secondary);white-space:nowrap}.admin-nav a[aria-current=page]{color:var(--primary);font-weight:800;border-bottom:2px solid var(--primary)}.admin-main{width:min(100% - 48px,1100px);margin:auto;padding:38px 0 80px}.login-panel{max-width:420px;margin:50px auto;padding:34px;background:#fff;border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow)}.login-heading{text-align:center}.login-heading .brand-mark{margin:auto}.login-heading h1{margin:18px 0 0}.login-heading p{margin:7px 0 24px;color:var(--muted)}.login-panel label{display:block;margin-bottom:17px;color:var(--secondary);font-size:13px;font-weight:700}.login-panel input{width:100%;height:44px;margin-top:7px;padding:10px 12px;border:1px solid #d4d4d8;border-radius:9px}.admin-error{padding:12px 14px;border-radius:9px;background:#fef2f2;color:#991b1b;font-size:13px}.admin-page-heading{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:28px}.admin-page-heading span{color:var(--primary);font-size:12px;font-weight:800;letter-spacing:.1em}.admin-page-heading h1{margin:5px 0 0;font-size:30px}.admin-page-heading p{margin:6px 0 0;color:var(--muted)}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}.metric-grid a{padding:24px;background:#fff;border:1px solid var(--border);border-radius:var(--radius);text-decoration:none}.metric-grid span{display:block;color:var(--muted);font-size:13px}.metric-grid strong{display:block;margin-top:8px;font-size:32px}.admin-table-wrap{overflow:hidden;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.admin-table{width:100%;border-collapse:collapse}.admin-table th,.admin-table td{padding:14px 16px;border-bottom:1px solid var(--border);text-align:left;font-size:13px}.admin-table th{background:#f8fafc;color:var(--muted)}.admin-table tr:last-child td{border:0}.admin-table td:first-child{font-size:14px}.admin-table td small{display:block;margin-top:3px;color:var(--muted)}.admin-table td a{color:var(--primary);font-weight:700}.status{display:inline-flex;padding:4px 9px;border-radius:999px;background:#f1f5f9;color:#475569;font-size:12px;font-weight:700}.status-published,.status-replied{background:#dcfce7;color:#15803d}.status-pending{background:#fef3c7;color:#b45309}.status-in_progress{background:#dbeafe;color:#1d4ed8}.inquiry-admin-list{display:grid;gap:14px}.inquiry-admin-list article{padding:20px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.inquiry-admin-list article>div{display:flex;justify-content:space-between}.inquiry-admin-list small{color:var(--muted)}.inquiry-admin-list h2{margin:12px 0 0;font-size:17px}.inquiry-admin-list h2 a{text-decoration:none}.inquiry-admin-list p{margin:7px 0 0;color:var(--secondary);font-size:13px}.admin-placeholder{padding:28px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)} +@media(max-width:760px){.admin-header-inner,.admin-nav>div,.admin-main{width:min(100% - 32px,1100px)}.admin-account>span{display:none}.metric-grid{grid-template-columns:1fr 1fr}.admin-page-heading{align-items:stretch;flex-direction:column}.admin-table-wrap{overflow:visible;background:transparent;border:0}.admin-table thead{display:none}.admin-table,.admin-table tbody,.admin-table tr,.admin-table td{display:block}.admin-table tr{margin-bottom:14px;padding:16px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.admin-table td{display:grid;grid-template-columns:72px 1fr;gap:10px;padding:7px 0;border:0}.admin-table td::before{content:attr(data-label);color:var(--muted);font-size:12px}.admin-table td:first-child{display:block;padding-bottom:12px;margin-bottom:6px;border-bottom:1px solid var(--border)}.admin-table td:first-child::before{display:none}} +@media(max-width:480px){.admin-account .button{display:none}.admin-main{padding-top:28px}.metric-grid{grid-template-columns:1fr 1fr;gap:10px}.metric-grid a{padding:18px}.login-panel{margin:26px 0;padding:24px}} +.admin-form{max-width:780px;padding:28px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.admin-form label{display:block;margin-bottom:17px;color:var(--secondary);font-size:13px;font-weight:700}.admin-form label>span{color:#dc2626}.admin-form input,.admin-form select,.admin-form textarea{width:100%;margin-top:7px;padding:10px 12px;border:1px solid #d4d4d8;border-radius:9px;background:#fff}.admin-form input,.admin-form select{height:44px}.admin-form textarea{resize:vertical;line-height:1.6}.admin-form label small{display:block;margin-top:5px;color:var(--muted);font-weight:400}.admin-field-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.admin-form-actions{display:flex;gap:10px;margin-top:24px}.danger-zone{max-width:780px;margin-top:24px;padding:20px 24px;display:flex;align-items:center;justify-content:space-between;gap:20px;border:1px solid #fecaca;border-radius:var(--radius);background:#fff}.danger-zone strong{color:#991b1b}.danger-zone p{margin:4px 0 0;color:var(--muted);font-size:13px}.danger-zone button{padding:9px 14px;border:1px solid #fecaca;border-radius:8px;background:#fff;color:#b91c1c;cursor:pointer}.inquiry-heading{margin-top:22px}.inquiry-detail-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:20px;align-items:start}.inquiry-content{padding:26px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.contact-summary{padding:16px;background:#f8fafc;border-radius:9px}.contact-summary>span{float:right;color:var(--primary);font-size:12px;font-weight:800}.contact-summary p{margin:0;line-height:1.8}.inquiry-content h2{margin-top:25px;font-size:17px}.inquiry-content>p{line-height:1.85}.reply-form{padding:26px}.reply-form h2{margin:0 0 20px} +@media(max-width:760px){.admin-field-grid,.inquiry-detail-grid{grid-template-columns:1fr}.danger-zone{align-items:stretch;flex-direction:column}.danger-zone button{width:100%}} diff --git a/public/assets/css/content.css b/public/assets/css/content.css new file mode 100644 index 0000000..76bdc59 --- /dev/null +++ b/public/assets/css/content.css @@ -0,0 +1,67 @@ +.post-thumbnail img{width:100%;height:100%;object-fit:cover} +.post-card{position:relative;transition:transform .2s ease,box-shadow .2s ease} +.post-card:hover{transform:translateY(-3px);box-shadow:0 12px 28px rgba(15,23,42,.1)} +.post-thumbnail>.post-category{z-index:3} +.post-placeholder{--placeholder-accent:#2563eb;position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:9px;overflow:hidden;background:linear-gradient(145deg,#eff6ff,#dbeafe);color:var(--placeholder-accent)} +.post-placeholder::before{content:"";position:absolute;inset:0;background-image:linear-gradient(rgba(37,99,235,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(37,99,235,.08) 1px,transparent 1px);background-size:34px 34px;mask-image:linear-gradient(to bottom right,#000,transparent 78%)} +.post-placeholder::after{content:"";position:absolute;right:-13%;bottom:-45%;width:62%;aspect-ratio:1;border:34px solid color-mix(in srgb,var(--placeholder-accent) 14%,transparent);border-radius:50%} +.post-placeholder[data-category="키폰시스템"]{--placeholder-accent:#7c3aed;background:linear-gradient(145deg,#f5f3ff,#ede9fe)} +.post-placeholder[data-category="CCTV"]{--placeholder-accent:#0f766e;background:linear-gradient(145deg,#f0fdfa,#ccfbf1)} +.post-placeholder[data-category="시공사례"]{--placeholder-accent:#c2410c;background:linear-gradient(145deg,#fff7ed,#ffedd5)} +.post-thumbnail .post-placeholder img{position:relative;z-index:1;width:136px;height:auto;object-fit:contain;filter:drop-shadow(0 5px 10px rgba(15,23,42,.08))} +.post-thumbnail .post-placeholder small{position:relative;z-index:1;padding:0;background:transparent;color:var(--placeholder-accent);font-size:10px;font-weight:800;letter-spacing:.2em} +.post-body h2 a::after,.post-body h3 a::after{inset:0;z-index:2} +.post-body time{position:relative;z-index:3} +.article-image{margin-inline:0;overflow:hidden} +.article-image img{width:100%;height:100%;object-fit:cover} +.admin-upload-field{margin:0 0 20px;padding:18px;border:1px solid var(--border);border-radius:10px} +.admin-upload-field legend{padding:0 7px;color:var(--secondary);font-size:13px;font-weight:800} +.admin-upload-field img{width:min(100%,360px);aspect-ratio:16/9;object-fit:cover;border-radius:8px} +.admin-upload-field>p{margin:8px 0 16px;color:var(--muted);font-size:13px} +.admin-notice{margin:0 0 18px;padding:12px 14px;border:1px solid #bbf7d0;border-radius:8px;background:#f0fdf4;color:#166534;font-size:13px} +.floating-call{display:inline-flex;align-items:center;gap:8px} +.floating-call svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} +.brand-logo{width:64px;height:auto;object-fit:contain;flex:none}.footer-brand .brand-logo{width:56px}.admin-brand .brand-logo{width:60px}.login-logo{width:112px;height:auto;margin:auto;object-fit:contain} +.hero-visual{overflow:hidden;background:#111827;display:block;text-align:initial;color:#fff} +.hero-slider{position:relative;isolation:isolate} +.hero-slides,.hero-slide{position:absolute;inset:0;margin:0} +.hero-slide{opacity:0;visibility:hidden;transition:opacity .55s ease,visibility .55s ease} +.hero-slide.is-active{opacity:1;visibility:visible} +.hero-slide::after{content:"";position:absolute;inset:auto 0 0;height:42%;background:linear-gradient(transparent,rgba(9,9,11,.72));pointer-events:none} +.hero-slide img{width:100%;height:100%;object-fit:cover;object-position:center} +.hero-slide figcaption{position:absolute;left:18px;right:18px;bottom:54px;z-index:1;color:#fff;font-size:14px;font-weight:700;text-shadow:0 1px 4px rgba(0,0,0,.5)} +.hero-slider .hero-slider-controls{position:absolute;left:14px;right:14px;bottom:12px;z-index:2;display:flex;align-items:center;gap:8px} +.hero-slider-controls button{border:0;color:#fff;cursor:pointer} +.hero-slider-arrow{width:32px;height:32px;border-radius:50%;background:rgba(9,9,11,.64);font-size:25px;line-height:1} +.hero-slider .hero-slider-dots{display:flex;align-items:center;gap:7px} +.hero-slider-dots button{width:8px;height:8px;padding:0;border-radius:50%;background:rgba(255,255,255,.5)} +.hero-slider-dots button.is-active{width:22px;border-radius:999px;background:#fff} +.hero-slider-pause{min-height:32px;margin-left:auto;padding:0 11px;border-radius:999px;background:rgba(9,9,11,.64);font-size:11px;font-weight:700} +@media(prefers-reduced-motion:reduce){.hero-slide{transition:none}} +@media(prefers-reduced-motion:reduce){.post-card{transition:none}.post-card:hover{transform:none}} +.contact-map-card>p{margin-bottom:18px} +.contact-form input::placeholder,.contact-form textarea::placeholder{color:#a1a1aa;font-weight:400;opacity:1} +.contact-form input::placeholder{font-size:14px} +.contact-form select{appearance:none;padding-right:42px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2352565f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;background-size:16px 16px} +.contact-form .form-field{min-width:0;margin:0 0 17px;padding:0;border:0} +.contact-form .form-field legend{padding:0;color:var(--secondary);font-size:13px;font-weight:700} +.email-template{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(128px,.8fr);align-items:center;gap:8px} +.email-template[hidden],.email-direct-row[hidden]{display:none} +.email-template>span{margin-top:7px;color:var(--secondary);font-size:15px;font-weight:700} +.contact-form .email-template input,.contact-form .email-template select,.contact-form .email-direct-row input{margin-top:7px} +.contact-form .email-template input::placeholder{font-size:13px;color:#b0b3ba} +.email-direct-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:8px} +.email-template-return{height:44px;padding:0 12px;border:1px solid #d4d4d8;border-radius:9px;background:#fff;color:var(--secondary);font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap} +.naver-map-frame{position:relative;height:300px;margin-top:18px;overflow:hidden;border:1px solid var(--border);border-radius:10px;background:#f1f5f9} +.naver-map{width:100%;height:100%!important} +.naver-map-status{position:absolute;inset:0;z-index:1;display:grid;place-items:center;margin:0!important;padding:24px;text-align:center;background:#f8fafc;color:var(--secondary)!important;font-size:14px} +.naver-map-frame[data-map-state=ready] .naver-map-status{display:none} +.naver-map-frame[data-map-state=error] .naver-map-status{background:#fff7ed;color:#9a3412!important} +.naver-map-fullscreen{position:absolute;right:10px;top:10px;z-index:5;width:38px;height:38px;display:grid;place-items:center;padding:0;border:1px solid rgba(15,23,42,.14);border-radius:8px;background:#fff;color:#27272a;box-shadow:0 2px 8px rgba(15,23,42,.14);cursor:pointer} +.naver-map-fullscreen[hidden]{display:none} +.naver-map-fullscreen svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} +.naver-map-frame:fullscreen{width:100vw;height:100vh;margin:0;border:0;border-radius:0;background:#fff} +.naver-map-frame.is-fullscreen .naver-map-fullscreen{right:16px;top:16px} +.naver-map-external{display:inline-flex;align-items:center;min-height:44px;margin-top:14px;color:var(--primary);font-weight:700} +.naver-map-frame[data-map-state=loading]+.naver-map-external,.naver-map-frame[data-map-state=ready]+.naver-map-external{display:none} +@media(max-width:640px){.post-card:hover{transform:none}.email-template{grid-template-columns:minmax(0,1fr) auto minmax(116px,.85fr);gap:6px}.email-direct-row{grid-template-columns:1fr}.email-template-return{width:100%;margin-top:0}.floating-call{display:none}} diff --git a/public/assets/css/site.css b/public/assets/css/site.css new file mode 100644 index 0000000..6c15683 --- /dev/null +++ b/public/assets/css/site.css @@ -0,0 +1,10 @@ +:root{--primary:#2563eb;--primary-dark:#1d4ed8;--primary-soft:#eff6ff;--success:#16a34a;--text:#18181b;--secondary:#52525b;--muted:#71717a;--border:#e4e4e7;--surface:#fff;--subtle:#f4f5f7;--bg:#fafafa;--radius:12px;--container:1200px;--shadow:0 12px 32px rgba(15,23,42,.08)} +*{box-sizing:border-box}html{scroll-behavior:smooth;-webkit-text-size-adjust:100%}body{margin:0;color:var(--text);background:var(--bg);font-family:Pretendard,-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;line-height:1.5}a{color:inherit}img,svg{display:block;max-width:100%}button,input,select,textarea{font:inherit}button,a{touch-action:manipulation}.container{width:min(100% - 48px,var(--container));margin-inline:auto}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.skip-link{position:fixed;left:16px;top:-80px;z-index:1000;padding:12px 16px;background:#111;color:#fff;border-radius:8px}.skip-link:focus{top:16px}.site-header{position:sticky;top:0;z-index:100;background:rgba(255,255,255,.96);border-bottom:1px solid var(--border);backdrop-filter:blur(16px)}.header-inner{width:min(100% - 48px,var(--container));height:72px;margin:auto;display:flex;align-items:center;gap:30px}.brand{display:inline-flex;align-items:center;gap:11px;text-decoration:none;flex:none}.brand-mark{width:42px;height:42px;border-radius:10px;background:var(--primary);display:grid;place-items:center;color:#fff}.brand-mark svg{width:25px;height:25px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.brand-copy{display:flex;flex-direction:column;line-height:1.08}.brand-copy strong{font-size:19px}.brand-copy em{color:var(--primary);font-style:normal}.brand-copy small{margin-top:3px;color:var(--muted);font-size:10px;font-weight:700;letter-spacing:.16em}.site-navigation{display:flex;align-items:center;gap:6px}.site-navigation a{position:relative;padding:12px 7px;text-decoration:none;color:var(--secondary);font-size:15px}.site-navigation a[aria-current=page]{font-weight:700;color:var(--text)}.site-navigation a[aria-current=page]::after{content:"";position:absolute;left:7px;right:7px;bottom:-14px;height:3px;border-radius:3px;background:var(--primary)}.header-actions{margin-left:auto;display:flex;align-items:center;gap:22px}.header-phone{text-decoration:none;font-weight:800}.menu-toggle{display:none;width:42px;height:42px;padding:8px;border:1px solid var(--border);border-radius:10px;background:#fff}.menu-toggle span:not(.sr-only){display:block;width:22px;height:2px;margin:4px auto;background:var(--text);transition:.2s}.button{min-height:44px;display:inline-flex;align-items:center;justify-content:center;padding:0 18px;border:1px solid transparent;border-radius:9px;text-decoration:none;font-weight:700;cursor:pointer}.button-primary{background:var(--primary);color:#fff}.button-primary:hover{background:var(--primary-dark)}.button-secondary{background:#fff;border-color:#d4d4d8;color:var(--text)}.button-small{min-height:38px;padding-inline:16px;font-size:14px}.button-light{background:#fff;color:var(--primary)}.button-outline-light{border-color:rgba(255,255,255,.65);color:#fff}.button-full{width:100%}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid rgba(37,99,235,.35);outline-offset:3px}.hero{position:relative;background:#fff;border-bottom:1px solid var(--border);overflow:hidden}.hero::before{content:"";position:absolute;inset:0;background:radial-gradient(900px 480px at 90% 0,#eff6ff,transparent 65%)}.hero-grid{position:relative;min-height:585px;padding-block:70px;display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.95fr);align-items:center;gap:58px}.eyebrow,.section-heading>span,.page-heading>span{display:inline-flex;color:var(--primary);font-size:13px;font-weight:800;letter-spacing:.09em}.hero h1{margin:20px 0 0;font-size:clamp(36px,4vw,54px);line-height:1.15;letter-spacing:-.035em}.hero p{max-width:590px;margin:22px 0 0;color:var(--secondary);font-size:18px;line-height:1.7}.hero-actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:32px}.hero small{display:block;margin-top:15px;color:var(--muted)}.hero-visual{aspect-ratio:4/3;border:1px solid var(--border);border-radius:18px;background:linear-gradient(145deg,#f8fafc,#eef2f7);box-shadow:var(--shadow);display:grid;place-items:center;text-align:center;color:var(--muted)}.hero-visual div{display:grid;gap:6px}.hero-visual strong{color:var(--secondary)}.hero-visual span{font-size:13px}.stats{background:#fff}.stats-grid{transform:translateY(28px);display:grid;grid-template-columns:repeat(4,1fr);background:#fff;border:1px solid var(--border);border-radius:14px;box-shadow:0 5px 18px rgba(15,23,42,.05);overflow:hidden}.stats-grid div{padding:25px;text-align:center;border-right:1px solid var(--border)}.stats-grid div:last-child{border:0}.stats-grid strong{display:block;font-size:30px}.stats-grid strong small{font-size:17px}.stats-grid span{display:block;margin-top:4px;color:var(--muted);font-size:13px}.section{padding:105px 0 80px}.section-heading{margin-bottom:38px}.section-heading.centered{text-align:center}.section-heading h2{margin:9px 0 0;font-size:34px;line-height:1.25}.section-heading p{margin:12px 0 0;color:var(--secondary)}.section-heading.row{display:flex;align-items:end;justify-content:space-between;gap:24px}.service-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px}.service-card,.post-card,.contact-form,.contact-card{background:#fff;border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 3px rgba(15,23,42,.04)}.service-card{padding:25px}.service-icon{width:48px;height:48px;border-radius:10px;background:var(--primary-soft);display:block}.service-card h3{margin:18px 0 0;font-size:18px}.service-card p{margin:10px 0;color:var(--secondary);line-height:1.7}.service-card a{color:var(--primary);font-weight:700;font-size:14px}.process{background:#f8fafc;border-block:1px solid var(--border)}.process-grid{margin:0;padding:0;list-style:none;display:grid;grid-template-columns:repeat(5,1fr);gap:16px}.process-grid li{min-height:165px;padding:22px;background:#fff;border:1px solid var(--border);border-radius:var(--radius);display:flex;flex-direction:column}.process-grid b{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;background:var(--primary);color:#fff}.process-grid strong{margin-top:20px}.process-grid span{margin-top:6px;color:var(--muted);font-size:13px}.post-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:22px}.post-card{overflow:hidden}.post-thumbnail{position:relative;aspect-ratio:16/9;background:linear-gradient(135deg,#f1f5f9,#e2e8f0);border-bottom:1px solid var(--border)}.post-thumbnail span{position:absolute;left:12px;top:12px;padding:4px 10px;border-radius:999px;background:#dbeafe;color:#1d4ed8;font-size:12px;font-weight:700}.post-body{padding:20px}.post-body h2,.post-body h3{margin:0;font-size:18px;line-height:1.45}.post-body h2 a,.post-body h3 a{text-decoration:none}.post-body h2 a::after,.post-body h3 a::after{content:"";position:absolute}.post-body p{min-height:48px;margin:10px 0 18px;color:var(--secondary);font-size:14px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}.post-body time{color:var(--muted);font-size:13px}.empty-state{padding:58px 24px;text-align:center;background:#fff;border:1px dashed #cbd5e1;border-radius:var(--radius)}.empty-state h2,.empty-state h3{margin:0}.empty-state p{margin:8px 0 0;color:var(--muted)}.page-section{padding:62px 0 90px}.page-heading{max-width:700px}.page-heading h1{margin:10px 0 0;font-size:clamp(30px,4vw,42px);line-height:1.25}.page-heading p{margin:14px 0 0;color:var(--secondary);font-size:16px}.category-filter{display:flex;flex-wrap:wrap;gap:9px;margin:32px 0 28px}.category-filter a{padding:8px 16px;border:1px solid #d4d4d8;border-radius:999px;background:#fff;text-decoration:none;font-size:14px}.category-filter a[aria-current=page]{background:var(--primary);border-color:var(--primary);color:#fff;font-weight:700}.pagination{display:flex;justify-content:center;gap:7px;margin-top:42px}.pagination a{width:38px;height:38px;display:grid;place-items:center;border:1px solid var(--border);border-radius:9px;background:#fff;text-decoration:none}.pagination a[aria-current=page]{background:var(--primary);color:#fff;border-color:var(--primary)}.article{width:min(100% - 48px,820px);margin:0 auto;padding:48px 0 90px}.back-link{color:var(--secondary);font-weight:700;text-decoration:none}.article header{margin-top:24px}.badge{display:inline-flex;padding:4px 10px;border-radius:999px;background:#dbeafe;color:#1d4ed8;font-size:12px;font-weight:700}.article header time{margin-left:10px;color:var(--muted);font-size:13px}.article h1{margin:16px 0 0;font-size:clamp(30px,4vw,42px);line-height:1.3}.article header p{padding-bottom:22px;border-bottom:1px solid var(--border);color:var(--muted)}.article-image{aspect-ratio:16/9;margin-top:28px;border:1px solid var(--border);border-radius:var(--radius);background:var(--subtle);display:grid;place-items:center;color:var(--muted)}.article-body{margin-top:32px}.article-body p{margin:0 0 22px;font-size:17px;line-height:1.9}.article-cta{margin-top:38px;padding:24px;display:flex;justify-content:space-between;align-items:center;gap:20px;background:var(--primary-soft);border:1px solid #dbeafe;border-radius:var(--radius)}.article-cta strong{font-size:17px}.article-cta p{margin:4px 0 0;color:var(--secondary)}.contact-layout{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(300px,.85fr);gap:26px;margin-top:36px;align-items:start}.contact-form{padding:32px}.contact-form h2{margin:0}.required-note{margin:4px 0 22px;color:var(--muted);font-size:13px}.required-note span,label>span{color:#dc2626}.field-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.contact-form label{display:block;margin-bottom:17px;color:var(--secondary);font-size:13px;font-weight:700}.contact-form input,.contact-form select,.contact-form textarea{width:100%;margin-top:7px;padding:10px 12px;border:1px solid #d4d4d8;border-radius:9px;background:#fff;color:var(--text)}.contact-form input,.contact-form select{height:44px}.contact-form textarea{resize:vertical;line-height:1.6}.contact-form label small{display:block;margin-top:6px;color:var(--muted);font-weight:400}.consent{display:flex!important;align-items:flex-start;gap:10px;padding:14px;background:var(--subtle);border-radius:9px}.consent input{width:18px!important;height:18px!important;margin:1px 0 0!important;flex:none}.consent span{color:var(--secondary);font-weight:500}.consent a{color:var(--primary)}.form-errors{margin-bottom:20px;padding:14px 16px;border:1px solid #fecaca;border-radius:9px;background:#fef2f2;color:#991b1b}.form-errors ul{margin:6px 0 0;padding-left:20px}.contact-side{display:grid;gap:16px}.contact-card{padding:26px}.contact-card h2{margin:0;font-size:17px}.contact-card p{margin:10px 0 0;color:var(--secondary);line-height:1.8}.contact-card.primary{background:var(--primary);color:#fff}.contact-card.primary>a{display:block;margin-top:8px;font-size:32px;font-weight:800;text-decoration:none}.contact-card.primary p{color:#dbeafe}.contact-card:not(.primary)>a{display:inline-block;margin-top:12px;color:var(--primary);font-weight:700}.policy{width:min(100% - 48px,820px);margin:auto;padding:58px 0 90px}.policy h1{font-size:38px}.policy h2{margin-top:36px;font-size:20px}.policy p{line-height:1.8;color:var(--secondary)}.policy-note{padding:14px 16px;background:#fff7ed;border:1px solid #fed7aa;border-radius:9px;color:#9a3412!important}.error-page{min-height:55vh;padding:80px 24px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}.error-page>span{font-size:56px;font-weight:900;color:#bfdbfe}.error-page h1{margin:0}.error-page p{color:var(--muted)}.cta-band{background:var(--primary);color:#fff}.cta-inner{min-height:250px;display:flex;justify-content:space-between;align-items:center;gap:30px}.cta-inner h2{margin:0;font-size:30px}.cta-inner p{margin:8px 0 0;color:#dbeafe}.cta-actions{display:flex;flex-wrap:wrap;gap:10px}.site-footer{background:#fff;border-top:1px solid var(--border)}.footer-grid{padding-block:48px;display:grid;grid-template-columns:1.4fr .7fr 1fr 1.1fr;gap:40px}.footer-grid h2{margin:0 0 12px;font-size:13px;color:var(--muted)}.footer-grid p{color:var(--secondary);font-size:13px;line-height:1.9}.footer-grid>div>a:not(.brand){display:block;margin:8px 0;text-decoration:none;font-size:13px}.footer-brand .brand-mark{width:36px;height:36px}.footer-bottom{padding-block:20px;border-top:1px solid var(--border);color:var(--muted);font-size:12px}.floating-call{position:fixed;right:22px;bottom:22px;z-index:80;padding:15px 20px;border-radius:999px;background:var(--primary);color:#fff;text-decoration:none;font-weight:800;box-shadow:var(--shadow)} +@media(max-width:960px){.header-inner{gap:16px}.menu-toggle{display:block;margin-left:auto}.site-navigation{display:none;position:absolute;left:0;right:0;top:72px;padding:10px 24px 18px;flex-direction:column;align-items:stretch;background:#fff;border-bottom:1px solid var(--border);box-shadow:0 10px 18px rgba(15,23,42,.08)}.site-navigation.is-open{display:flex}.site-navigation a{padding:12px}.site-navigation a[aria-current=page]::after{display:none}.header-actions{margin-left:0}.header-phone{display:none}.hero-grid{grid-template-columns:1fr;min-height:auto}.hero-visual{max-width:620px;width:100%}.service-grid{grid-template-columns:repeat(2,1fr)}.process-grid{grid-template-columns:repeat(3,1fr)}.post-grid{grid-template-columns:repeat(2,1fr)}.contact-layout{grid-template-columns:1fr}.footer-grid{grid-template-columns:repeat(2,1fr)}} +@media(max-width:640px){.container,.header-inner{width:min(100% - 32px,var(--container))}.header-inner{height:64px}.brand-mark{width:38px;height:38px}.brand-copy strong{font-size:17px}.brand-copy small{font-size:9px}.header-actions .button{display:none}.site-navigation{top:64px;padding-inline:16px}.hero-grid{padding-block:48px;gap:34px}.hero h1{font-size:36px}.hero p{font-size:16px}.hero-actions .button{width:100%}.stats-grid{grid-template-columns:repeat(2,1fr)}.stats-grid div:nth-child(2){border-right:0}.stats-grid div:nth-child(-n+2){border-bottom:1px solid var(--border)}.section{padding:82px 0 62px}.section-heading h2{font-size:29px}.section-heading.row{align-items:start;flex-direction:column}.service-grid,.post-grid{grid-template-columns:1fr}.process-grid{grid-template-columns:1fr 1fr}.process-grid li{min-height:145px}.page-section{padding-top:42px}.field-grid{grid-template-columns:1fr;gap:0}.contact-form{padding:22px}.article,.policy{width:min(100% - 32px,820px)}.article-cta{align-items:stretch;flex-direction:column}.article-cta .button{width:100%}.cta-inner{padding-block:48px;align-items:stretch;flex-direction:column;justify-content:center}.cta-inner h2{font-size:26px}.cta-actions .button{width:100%}.footer-grid{grid-template-columns:1fr;gap:25px}.floating-call{right:14px;bottom:14px;padding:12px 15px;font-size:14px}} +@media(max-width:359px){.brand-copy small{display:none}.hero h1{font-size:31px}.process-grid{grid-template-columns:1fr}} +@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}} +.narrow{max-width:680px}.success-panel,.lookup-form,.inquiry-result{margin-top:28px;padding:32px;background:#fff;border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}.success-panel{text-align:center}.success-panel>span{width:54px;height:54px;margin:auto;border-radius:50%;display:grid;place-items:center;background:#dcfce7;color:var(--success);font-size:28px;font-weight:900}.success-panel h1{margin:18px 0 0}.success-panel>p{color:var(--secondary)}.success-panel dl{margin:26px 0;padding:4px 18px;background:var(--subtle);border-radius:9px;text-align:left}.success-panel dl div{display:flex;justify-content:space-between;gap:16px;padding:12px 0;border-bottom:1px solid var(--border)}.success-panel dl div:last-child{border:0}.success-panel dt{color:var(--muted)}.success-panel dd{margin:0;font-weight:700}.success-help{font-size:13px}.success-actions{display:flex;justify-content:center;gap:10px;margin-top:24px}.lookup-form label{display:block;margin-bottom:17px;color:var(--secondary);font-size:13px;font-weight:700}.lookup-form input{width:100%;height:44px;margin-top:7px;padding:10px 12px;border:1px solid #d4d4d8;border-radius:9px}.lookup-form .form-errors{display:block}.inquiry-result header{padding-bottom:22px;border-bottom:1px solid var(--border)}.inquiry-result header small{float:right;color:var(--muted)}.inquiry-result h1{margin:14px 0 4px}.inquiry-result time{color:var(--muted);font-size:13px}.inquiry-result section{padding-top:22px}.inquiry-result section h2{font-size:17px}.inquiry-result section p{line-height:1.8;white-space:normal}.inquiry-result .reply{margin-top:22px;padding:22px;background:var(--primary-soft);border-radius:9px} +.policy{width:min(100% - 48px,940px)}.policy-heading>span{color:var(--primary);font-size:13px;font-weight:800;letter-spacing:.09em}.policy-heading h1{margin:10px 0 0}.policy h2{margin-top:48px;font-size:22px}.policy h3{font-size:16px}.policy li{line-height:1.8;color:var(--secondary)}.policy a{color:var(--primary)}.policy-index{margin-top:32px;padding:22px 26px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.policy-index ol{margin:0;padding-left:22px;columns:2;column-gap:36px}.policy-index li{break-inside:avoid;margin:5px 0}.policy-table-wrap{margin-top:18px;overflow-x:auto;border:1px solid var(--border);border-radius:10px}.policy table{width:100%;min-width:760px;border-collapse:collapse;background:#fff}.policy th,.policy td{padding:14px 16px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);text-align:left;vertical-align:top;font-size:14px;line-height:1.7}.policy th{background:#f8fafc}.policy tr:last-child th,.policy tr:last-child td{border-bottom:0}.policy th:last-child,.policy td:last-child{border-right:0}.policy ul{padding-left:22px}.policy-contact{padding:20px 22px;background:var(--subtle);border-radius:10px}.policy-contact h3{margin:0 0 12px}.policy-contact dl{margin:0}.policy-contact dl div{display:flex;gap:18px;padding:5px 0}.policy-contact dt{width:58px;color:var(--muted);font-weight:700}.policy-contact dd{margin:0}.policy-effective{padding:16px 18px;background:var(--subtle);border-radius:9px}.consent-notice{margin:4px 0 14px;padding:18px;border:1px solid var(--border);border-radius:9px;background:#fff}.consent-notice h3{margin:0 0 12px;font-size:15px}.consent-notice dl{margin:0}.consent-notice dl div{display:grid;grid-template-columns:86px 1fr;gap:12px;padding:7px 0;border-bottom:1px solid var(--border)}.consent-notice dl div:last-child{border:0}.consent-notice dt{color:var(--muted);font-size:12px;font-weight:700}.consent-notice dd{margin:0;color:var(--secondary);font-size:12px;line-height:1.6}.consent-notice>p{margin:12px 0 0;color:var(--muted);font-size:12px;line-height:1.6} +@media(max-width:640px){.policy{width:min(100% - 32px,940px)}.policy-index ol{columns:1}.policy h1{font-size:32px}.policy h2{font-size:20px}.consent-notice dl div{grid-template-columns:1fr;gap:2px}} +.service-icon{--service-icon-color:var(--primary);display:grid;place-items:center}.service-icon svg{width:26px;height:26px;fill:none;stroke:var(--service-icon-color);stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}.service-icon.type-keyphone{--service-icon-color:#7c3aed;background:#f5f3ff}.service-icon.type-cctv{--service-icon-color:#059669;background:#ecfdf5}.service-icon.type-maintenance{--service-icon-color:#ea580c;background:#fff7ed} diff --git a/public/assets/images/company-logo-v2.png b/public/assets/images/company-logo-v2.png new file mode 100644 index 0000000..136d85b Binary files /dev/null and b/public/assets/images/company-logo-v2.png differ diff --git a/public/assets/images/company-logo-v3.png b/public/assets/images/company-logo-v3.png new file mode 100644 index 0000000..a80ee55 Binary files /dev/null and b/public/assets/images/company-logo-v3.png differ diff --git a/public/assets/images/field/cctv-control-rack.jpg b/public/assets/images/field/cctv-control-rack.jpg new file mode 100644 index 0000000..746e62c Binary files /dev/null and b/public/assets/images/field/cctv-control-rack.jpg differ diff --git a/public/assets/images/field/keyphone-network-rack.jpg b/public/assets/images/field/keyphone-network-rack.jpg new file mode 100644 index 0000000..ee067e6 Binary files /dev/null and b/public/assets/images/field/keyphone-network-rack.jpg differ diff --git a/public/assets/images/field/lan-cabling-rack.jpg b/public/assets/images/field/lan-cabling-rack.jpg new file mode 100644 index 0000000..005dc56 Binary files /dev/null and b/public/assets/images/field/lan-cabling-rack.jpg differ diff --git a/public/assets/images/field/outdoor-wireless-link.jpg b/public/assets/images/field/outdoor-wireless-link.jpg new file mode 100644 index 0000000..bf2da73 Binary files /dev/null and b/public/assets/images/field/outdoor-wireless-link.jpg differ diff --git a/public/assets/images/main-construction-hero.jpg b/public/assets/images/main-construction-hero.jpg new file mode 100644 index 0000000..1122330 Binary files /dev/null and b/public/assets/images/main-construction-hero.jpg differ diff --git a/public/assets/js/site.js b/public/assets/js/site.js new file mode 100644 index 0000000..0aa0786 --- /dev/null +++ b/public/assets/js/site.js @@ -0,0 +1,315 @@ +(() => { + const toggle = document.querySelector('.menu-toggle'); + const navigation = document.querySelector('.site-navigation'); + if (!toggle || !navigation) return; + + const close = () => { + navigation.classList.remove('is-open'); + toggle.setAttribute('aria-expanded', 'false'); + toggle.querySelector('.sr-only').textContent = '메뉴 열기'; + }; + + toggle.addEventListener('click', () => { + const open = !navigation.classList.contains('is-open'); + navigation.classList.toggle('is-open', open); + toggle.setAttribute('aria-expanded', String(open)); + toggle.querySelector('.sr-only').textContent = open ? '메뉴 닫기' : '메뉴 열기'; + }); + + navigation.addEventListener('click', (event) => { + if (event.target.closest('a')) close(); + }); + + window.addEventListener('resize', () => { + if (window.innerWidth > 960) close(); + }); +})(); + +(() => { + const slider = document.querySelector('[data-hero-slider]'); + if (!slider) return; + + const slides = [...slider.querySelectorAll('.hero-slide')]; + const dots = [...slider.querySelectorAll('[data-slider-dot]')]; + const previous = slider.querySelector('[data-slider-prev]'); + const next = slider.querySelector('[data-slider-next]'); + const pause = slider.querySelector('[data-slider-pause]'); + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + let current = 0; + let timer = null; + let isPaused = false; + let isInteracting = false; + + const show = (index) => { + current = (index + slides.length) % slides.length; + slides.forEach((slide, slideIndex) => { + const isActive = slideIndex === current; + slide.classList.toggle('is-active', isActive); + slide.setAttribute('aria-hidden', String(!isActive)); + }); + dots.forEach((dot, dotIndex) => { + const isActive = dotIndex === current; + dot.classList.toggle('is-active', isActive); + if (isActive) dot.setAttribute('aria-current', 'true'); + else dot.removeAttribute('aria-current'); + }); + }; + + const stop = () => { + if (timer !== null) window.clearInterval(timer); + timer = null; + }; + + const start = () => { + stop(); + if (isPaused || isInteracting || reducedMotion.matches || document.hidden) return; + timer = window.setInterval(() => show(current + 1), 5000); + }; + + const move = (direction) => { + show(current + direction); + start(); + }; + + previous?.addEventListener('click', () => move(-1)); + next?.addEventListener('click', () => move(1)); + dots.forEach((dot) => dot.addEventListener('click', () => { + show(Number(dot.dataset.sliderDot)); + start(); + })); + pause?.addEventListener('click', () => { + isPaused = !isPaused; + pause.setAttribute('aria-pressed', String(isPaused)); + pause.textContent = isPaused ? '자동재생 시작' : '자동재생 일시정지'; + start(); + }); + slider.addEventListener('mouseenter', () => { + isInteracting = true; + stop(); + }); + slider.addEventListener('mouseleave', () => { + isInteracting = false; + start(); + }); + slider.addEventListener('focusin', () => { + isInteracting = true; + stop(); + }); + slider.addEventListener('focusout', () => { + isInteracting = false; + start(); + }); + document.addEventListener('visibilitychange', start); + reducedMotion.addEventListener('change', start); + start(); +})(); + +(() => { + const phoneInput = document.querySelector('[data-phone-input]'); + const emailField = document.querySelector('[data-email-field]'); + + if (phoneInput) { + const formatPhone = (value) => { + let numbers = value.replace(/\D/g, ''); + + if (numbers.startsWith('02')) { + numbers = numbers.slice(0, 10); + if (numbers.length <= 2) return numbers; + if (numbers.length <= 5) return `${numbers.slice(0, 2)}-${numbers.slice(2)}`; + if (numbers.length <= 9) return `${numbers.slice(0, 2)}-${numbers.slice(2, 5)}-${numbers.slice(5)}`; + return `${numbers.slice(0, 2)}-${numbers.slice(2, 6)}-${numbers.slice(6)}`; + } + + if (/^1\d{3}/.test(numbers)) { + numbers = numbers.slice(0, 8); + return numbers.length <= 4 ? numbers : `${numbers.slice(0, 4)}-${numbers.slice(4)}`; + } + + if (/^050\d/.test(numbers)) { + numbers = numbers.slice(0, 12); + if (numbers.length <= 4) return numbers; + if (numbers.length <= 8) return `${numbers.slice(0, 4)}-${numbers.slice(4)}`; + return `${numbers.slice(0, 4)}-${numbers.slice(4, 8)}-${numbers.slice(8)}`; + } + + numbers = numbers.slice(0, 11); + if (numbers.length <= 3) return numbers; + if (numbers.length <= 6) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`; + if (numbers.length <= 10) return `${numbers.slice(0, 3)}-${numbers.slice(3, 6)}-${numbers.slice(6)}`; + return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7)}`; + }; + + const applyPhoneFormat = () => { + phoneInput.value = formatPhone(phoneInput.value); + }; + + phoneInput.addEventListener('input', applyPhoneFormat); + applyPhoneFormat(); + } + + document.querySelectorAll('[data-pin-input]').forEach((pinInput) => { + const applyPinFormat = () => { + pinInput.value = pinInput.value.replace(/\D/g, '').slice(0, 4); + }; + pinInput.addEventListener('input', applyPinFormat); + applyPinFormat(); + }); + + if (!emailField) return; + + const template = emailField.querySelector('[data-email-template]'); + const localInput = emailField.querySelector('[data-email-local]'); + const domainSelect = emailField.querySelector('[data-email-domain]'); + const directRow = emailField.querySelector('[data-email-direct]'); + const fullInput = emailField.querySelector('[data-email-full]'); + const returnButton = emailField.querySelector('[data-email-return]'); + if (!template || !localInput || !domainSelect || !directRow || !fullInput || !returnButton) return; + + const domains = [...domainSelect.options].map((option) => option.value).filter((value) => value !== 'direct'); + const splitEmail = (email) => { + const separator = email.lastIndexOf('@'); + if (separator <= 0) return null; + return { local: email.slice(0, separator), domain: email.slice(separator + 1).toLowerCase() }; + }; + const syncEmail = () => { + fullInput.value = localInput.value && domainSelect.value !== 'direct' + ? `${localInput.value}@${domainSelect.value}` + : ''; + }; + const showTemplate = () => { + const parsed = splitEmail(fullInput.value); + localInput.value = parsed?.local || fullInput.value; + domainSelect.value = parsed && domains.includes(parsed.domain) ? parsed.domain : 'naver.com'; + template.hidden = false; + directRow.hidden = true; + returnButton.hidden = true; + syncEmail(); + }; + const showDirect = () => { + const local = localInput.value.trim(); + fullInput.value = local ? `${local}@` : ''; + template.hidden = true; + directRow.hidden = false; + returnButton.hidden = false; + fullInput.focus(); + }; + + localInput.addEventListener('input', syncEmail); + domainSelect.addEventListener('change', () => { + if (domainSelect.value === 'direct') showDirect(); + else syncEmail(); + }); + returnButton.addEventListener('click', () => { + showTemplate(); + localInput.focus(); + }); + emailField.closest('form')?.addEventListener('submit', () => { + if (!template.hidden) syncEmail(); + }); + + const initialEmail = splitEmail(fullInput.value); + if (!fullInput.value || (initialEmail && domains.includes(initialEmail.domain))) showTemplate(); + else returnButton.hidden = false; +})(); + +(() => { + const mapFrame = document.querySelector('[data-naver-map]'); + if (!mapFrame) return; + + const mapElement = mapFrame.querySelector('.naver-map'); + const statusElement = mapFrame.querySelector('[data-naver-map-status]'); + const fullscreenButton = mapFrame.querySelector('[data-naver-map-fullscreen]'); + const externalLink = mapFrame.parentElement?.querySelector('[data-naver-map-external]'); + const clientId = mapFrame.dataset.clientId; + const latitude = Number(mapFrame.dataset.latitude); + const longitude = Number(mapFrame.dataset.longitude); + const label = mapFrame.dataset.label; + let settled = false; + + const setState = (state, message = '') => { + settled = state !== 'loading'; + mapFrame.dataset.mapState = state; + mapFrame.setAttribute('aria-busy', String(state === 'loading')); + if (fullscreenButton) fullscreenButton.hidden = state !== 'ready'; + if (statusElement && message) statusElement.textContent = message; + }; + + const fail = () => { + setState('error', '지도를 불러오지 못했습니다. 아래 네이버 지도 링크를 이용해 주세요.'); + }; + + const renderMap = () => { + if (!mapElement || !window.naver?.maps || !Number.isFinite(latitude) || !Number.isFinite(longitude)) { + fail(); + return; + } + + const position = new window.naver.maps.LatLng(latitude, longitude); + const map = new window.naver.maps.Map(mapElement, { + center: position, + zoom: 17, + minZoom: 10, + zoomControl: true, + zoomControlOptions: { + position: window.naver.maps.Position.RIGHT_CENTER, + }, + }); + + new window.naver.maps.Marker({ + map, + position, + title: label, + }); + + const refreshMapSize = () => { + window.setTimeout(() => { + window.naver.maps.Event.trigger(map, 'resize'); + map.setCenter(position); + }, 0); + }; + document.addEventListener('fullscreenchange', () => { + const isFullscreen = document.fullscreenElement === mapFrame; + mapFrame.classList.toggle('is-fullscreen', isFullscreen); + if (fullscreenButton) { + fullscreenButton.setAttribute('aria-label', isFullscreen ? '전체 화면 종료' : '지도를 전체 화면으로 보기'); + fullscreenButton.title = isFullscreen ? '전체 화면 종료' : '전체 화면'; + } + refreshMapSize(); + }); + if (fullscreenButton && !mapFrame.requestFullscreen) { + fullscreenButton.setAttribute('aria-label', '네이버 지도에서 크게 보기'); + fullscreenButton.title = '네이버 지도에서 열기'; + } + fullscreenButton?.addEventListener('click', async () => { + if (document.fullscreenElement === mapFrame) { + await document.exitFullscreen(); + } else if (mapFrame.requestFullscreen) { + await mapFrame.requestFullscreen(); + } else if (externalLink) { + window.open(externalLink.href, '_blank', 'noopener'); + } + }); + setState('ready', '지도를 불러왔습니다.'); + }; + + if (!clientId) { + fail(); + return; + } + + setState('loading'); + window.navermap_authFailure = fail; + const script = document.createElement('script'); + const parameters = new URLSearchParams({ + ncpKeyId: clientId, + }); + script.src = `https://oapi.map.naver.com/openapi/v3/maps.js?${parameters}`; + script.async = true; + script.onload = renderMap; + script.onerror = fail; + document.head.append(script); + + window.setTimeout(() => { + if (!settled) fail(); + }, 12000); +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..150c3d2 --- /dev/null +++ b/public/index.html @@ -0,0 +1,293 @@ + + + + + + + + + (주)지티에스정보통신 | 사이트 준비 중 + + + +
+
+ + + + (주)지티에스정보통신 + + + 1833-4917 +
+ +
+
+ 사이트 준비 중 +

더 나은 통신 환경을
준비하고 있습니다

+

+ 네트워크·키폰·CCTV 설계부터 시공과 유지보수까지,
+ (주)지티에스정보통신의 새로운 사이트로 곧 찾아뵙겠습니다. +

+
+ + + + + 대표번호 1833-4917 + + 경기지점 1833-4918 +
+

상담시간 평일 09:00–18:00 · 주말 및 공휴일 휴무

+
+
+ +
© 2026 (주)지티에스정보통신. All rights reserved.
+
+ + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..8f92286 --- /dev/null +++ b/public/index.php @@ -0,0 +1,573 @@ +latest(3); + } + render('home', [ + 'title' => '사무실 통신 인프라 전문', + 'description' => '네트워크, 키폰, CCTV 설계부터 시공과 유지보수까지 책임지는 (주)지티에스정보통신입니다.', + 'posts' => $posts, + ]); + exit; +} + +if ($path === '/blog' && $method === 'GET') { + $page = max(1, (int) ($_GET['page'] ?? 1)); + $category = trim((string) ($_GET['category'] ?? '')); + $result = ['items' => [], 'total' => 0]; + if ($postRepository !== null) { + $result = $postRepository->paginate($page, 6, $category ?: null); + } + render('blog/index', [ + 'title' => '시공 이야기와 정보', + 'description' => '네트워크, 키폰, CCTV 시공 사례와 장비 선택 정보를 확인하세요.', + 'posts' => $result['items'], + 'total' => $result['total'], + 'page' => $page, + 'perPage' => 6, + 'category' => $category, + ]); + exit; +} + +if (preg_match('#^/blog/([a-z0-9-]+)$#', $path, $matches) === 1 && $method === 'GET') { + $post = $postRepository?->findPublishedBySlug($matches[1]); + if ($post === null) { + http_response_code(404); + render('errors/404', ['title' => '게시글을 찾을 수 없습니다']); + exit; + } + render('blog/show', [ + 'title' => $post['title'], + 'description' => $post['excerpt'], + 'post' => $post, + ]); + exit; +} + +if ($path === '/contact' && $method === 'GET') { + render('contact', [ + 'title' => '1:1 비공개 상담 신청', + 'description' => '네트워크, 키폰, CCTV 구축과 유지보수 상담을 신청하세요.', + 'errors' => [], + ]); + exit; +} + +if ($path === '/contact' && $method === 'POST') { + verify_csrf(); + $allowedTypes = ['네트워크 구축', '키폰시스템', 'CCTV 설치', '시공·유지보수', '기타 문의']; + $data = [ + 'name' => trim((string) ($_POST['name'] ?? '')), + 'phone' => trim((string) ($_POST['phone'] ?? '')), + 'email' => trim((string) ($_POST['email'] ?? '')), + 'type' => trim((string) ($_POST['type'] ?? '')), + 'title' => trim((string) ($_POST['title'] ?? '')), + 'body' => trim((string) ($_POST['body'] ?? '')), + 'password' => (string) ($_POST['password'] ?? ''), + ]; + $errors = []; + if ($data['name'] === '' || mb_strlen($data['name']) > 120) $errors[] = '이름 또는 회사명을 확인해 주세요.'; + if (!preg_match('/^(?:02-\d{3,4}-\d{4}|0\d{2}-\d{3,4}-\d{4}|050\d-\d{4}-\d{4}|1\d{3}-\d{4})$/D', $data['phone'])) $errors[] = '연락처 형식을 확인해 주세요.'; + if ($data['email'] !== '' && filter_var($data['email'], FILTER_VALIDATE_EMAIL) === false) $errors[] = '이메일 형식을 확인해 주세요.'; + if (!in_array($data['type'], $allowedTypes, true)) $errors[] = '문의 유형을 선택해 주세요.'; + if ($data['title'] === '' || mb_strlen($data['title']) > 200) $errors[] = '제목을 200자 이내로 입력해 주세요.'; + if ($data['body'] === '' || mb_strlen($data['body']) > 5000) $errors[] = '문의 내용을 5,000자 이내로 입력해 주세요.'; + if (!preg_match('/^[0-9]{4}$/D', $data['password'])) $errors[] = '조회 PIN은 숫자 4자리로 입력해 주세요.'; + if (($_POST['consent'] ?? '') !== '1') $errors[] = '개인정보 수집·이용 동의가 필요합니다.'; + + $lastSubmit = (int) ($_SESSION['last_inquiry_submit'] ?? 0); + if (time() - $lastSubmit < 20) $errors[] = '중복 접수를 방지하기 위해 잠시 후 다시 시도해 주세요.'; + + if ($errors) { + http_response_code(422); + render('contact', [ + 'title' => '1:1 비공개 상담 신청', + 'description' => '네트워크, 키폰, CCTV 구축과 유지보수 상담을 신청하세요.', + 'errors' => $errors, + ]); + exit; + } + + try { + $ticket = (new InquiryRepository())->create($data); + $_SESSION['last_inquiry_submit'] = time(); + $_SESSION['submitted_ticket'] = $ticket; + redirect('/contact/success'); + } catch (Throwable) { + http_response_code(503); + render('contact', [ + 'title' => '1:1 비공개 상담 신청', + 'description' => '네트워크, 키폰, CCTV 구축과 유지보수 상담을 신청하세요.', + 'errors' => ['현재 온라인 접수가 원활하지 않습니다. 대표번호로 문의해 주세요.'], + ]); + exit; + } +} + +if ($path === '/contact/success' && $method === 'GET') { + $ticket = $_SESSION['submitted_ticket'] ?? null; + if (!is_string($ticket) || $ticket === '') redirect('/contact'); + unset($_SESSION['submitted_ticket']); + render('inquiries/success', ['title' => '문의 접수 완료', 'ticket' => $ticket]); + exit; +} + +if ($path === '/inquiry/lookup' && $method === 'GET') { + render('inquiries/lookup', [ + 'title' => '문의 접수 확인', + 'description' => '접수번호와 숫자 4자리 조회 PIN으로 상담 처리 상태를 확인하세요.', + 'ticket' => trim((string) ($_GET['ticket'] ?? '')), + 'error' => '', + ]); + exit; +} + +if ($path === '/inquiry/lookup' && $method === 'POST') { + verify_csrf(); + $window = $_SESSION['lookup_rate'] ?? ['started' => time(), 'attempts' => 0]; + if (time() - (int) $window['started'] > 900) $window = ['started' => time(), 'attempts' => 0]; + if ((int) $window['attempts'] >= 5) { + http_response_code(429); + render('inquiries/lookup', ['title'=>'문의 접수 확인','ticket'=>'','error'=>'잠시 후 다시 시도해 주세요.']); + exit; + } + $ticket = strtoupper(trim((string) ($_POST['ticket'] ?? ''))); + $password = (string) ($_POST['password'] ?? ''); + $inquiry = null; + $inquiryRepository = null; + try { + $inquiryRepository = new InquiryRepository(); + $inquiry = $inquiryRepository->findForLookup($ticket); + } catch (Throwable) {} + + if ($inquiry !== null && !empty($inquiry['lookup_locked_until'])) { + if (strtotime((string) $inquiry['lookup_locked_until']) > time()) { + http_response_code(429); + render('inquiries/lookup', ['title'=>'문의 접수 확인','ticket'=>$ticket,'error'=>'조회 시도가 제한되었습니다. 15분 후 다시 시도해 주세요.']); + exit; + } + try { $inquiryRepository?->resetLookupLimit((int) $inquiry['id']); } catch (Throwable) {} + } + + $validPin = preg_match('/^[0-9]{4}$/D', $password) === 1; + if ($inquiry === null || !$validPin || !password_verify($password, $inquiry['password_hash'])) { + $window['attempts']++; + $_SESSION['lookup_rate'] = $window; + $locked = false; + if ($inquiry !== null && $inquiryRepository instanceof InquiryRepository) { + try { $locked = $inquiryRepository->recordLookupFailure((int) $inquiry['id']); } catch (Throwable) {} + } + http_response_code($locked ? 429 : 422); + render('inquiries/lookup', [ + 'title'=>'문의 접수 확인', + 'ticket'=>$ticket, + 'error'=>$locked ? '조회 시도가 제한되었습니다. 15분 후 다시 시도해 주세요.' : '접수번호 또는 조회 PIN이 올바르지 않습니다.', + ]); + exit; + } + try { $inquiryRepository?->resetLookupLimit((int) $inquiry['id']); } catch (Throwable) {} + unset($_SESSION['lookup_rate'], $inquiry['id'], $inquiry['password_hash'], $inquiry['lookup_failed_attempts'], $inquiry['lookup_locked_until']); + render('inquiries/show', ['title'=>'문의 처리 상태','description'=>'비공개 상담 처리 상태입니다.','inquiry'=>$inquiry]); + exit; +} + +if ($path === '/privacy' && $method === 'GET') { + render('privacy', [ + 'title' => '개인정보처리방침', + 'description' => '(주)지티에스정보통신 개인정보처리방침입니다.', + ]); + exit; +} + +if ($path === '/admin/setup' && $method === 'GET') { + try { + $adminRepository = new AdminRepository(); + if ($adminRepository->adminCount() > 0) redirect('/admin/login'); + } catch (Throwable) { + http_response_code(503); + render('errors/404', ['title'=>'관리자 등록을 준비할 수 없습니다'], 'layouts/admin'); + exit; + } + + $token = (string) ($_GET['token'] ?? ''); + $tokenFile = (string) (getenv('GTSIT_ADMIN_SETUP_TOKEN_FILE') ?: ''); + if ($token !== '' && $tokenFile !== '' && is_readable($tokenFile) && time() - (int) filemtime($tokenFile) <= 86400) { + $storedHash = trim((string) file_get_contents($tokenFile)); + if ($storedHash !== '' && hash_equals($storedHash, hash('sha256', $token))) { + session_regenerate_id(true); + $_SESSION['admin_setup_verified'] = time(); + redirect('/admin/setup'); + } + } + + if (time() - (int) ($_SESSION['admin_setup_verified'] ?? 0) > 900) { + unset($_SESSION['admin_setup_verified']); + http_response_code(404); + render('errors/404', ['title'=>'페이지를 찾을 수 없습니다'], 'layouts/admin'); + exit; + } + + render('admin/setup', [ + 'title'=>'관리자 계정 등록', + 'username'=>(string) (getenv('GTSIT_INITIAL_ADMIN_USERNAME') ?: 'gts0201'), + 'displayName'=>'관리자', + 'errors'=>[], + ], 'layouts/admin'); + exit; +} + +if ($path === '/admin/setup' && $method === 'POST') { + verify_csrf(); + if (time() - (int) ($_SESSION['admin_setup_verified'] ?? 0) > 900) { + unset($_SESSION['admin_setup_verified']); + http_response_code(404); + render('errors/404', ['title'=>'페이지를 찾을 수 없습니다'], 'layouts/admin'); + exit; + } + + $username = (string) (getenv('GTSIT_INITIAL_ADMIN_USERNAME') ?: 'gts0201'); + $displayName = trim((string) ($_POST['display_name'] ?? '')); + $password = (string) ($_POST['password'] ?? ''); + $passwordConfirmation = (string) ($_POST['password_confirmation'] ?? ''); + $errors = []; + if ($displayName === '' || mb_strlen($displayName) > 100) $errors[] = '표시 이름을 100자 이내로 입력해 주세요.'; + if (strlen($password) < 12 || strlen($password) > 72 || !preg_match('/[A-Za-z]/', $password) || !preg_match('/[0-9]/', $password)) { + $errors[] = '비밀번호는 영문과 숫자를 포함해 12자 이상 입력해 주세요.'; + } + if (!hash_equals($password, $passwordConfirmation)) $errors[] = '비밀번호 확인이 일치하지 않습니다.'; + + if ($errors) { + http_response_code(422); + render('admin/setup', compact('username', 'displayName', 'errors') + ['title'=>'관리자 계정 등록'], 'layouts/admin'); + exit; + } + + try { + $adminRepository = new AdminRepository(); + if ($adminRepository->adminCount() > 0) redirect('/admin/login'); + $adminRepository->createAdmin($username, $displayName, $password); + $tokenFile = (string) (getenv('GTSIT_ADMIN_SETUP_TOKEN_FILE') ?: ''); + if ($tokenFile !== '' && is_file($tokenFile)) unlink($tokenFile); + unset($_SESSION['admin_setup_verified']); + $_SESSION['admin_created'] = true; + redirect('/admin/login'); + } catch (Throwable) { + http_response_code(503); + render('admin/setup', [ + 'title'=>'관리자 계정 등록','username'=>$username,'displayName'=>$displayName, + 'errors'=>['계정을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.'], + ], 'layouts/admin'); + exit; + } +} + +if ($path === '/admin/login' && $method === 'GET') { + if (AdminAuth::check()) redirect('/admin'); + $notice = !empty($_SESSION['admin_created']) ? '관리자 계정이 생성되었습니다. 설정한 비밀번호로 로그인해 주세요.' : ''; + unset($_SESSION['admin_created']); + render('admin/login', ['title'=>'관리자 로그인','error'=>'','notice'=>$notice,'username'=>'gts0201'], 'layouts/admin'); + exit; +} + +if ($path === '/admin/login' && $method === 'POST') { + verify_csrf(); + $username = trim((string) ($_POST['username'] ?? '')); + $password = (string) ($_POST['password'] ?? ''); + $loginRate = $_SESSION['admin_login_rate'] ?? ['started'=>time(),'attempts'=>0]; + if (time() - (int)$loginRate['started'] > 900) $loginRate = ['started'=>time(),'attempts'=>0]; + if ((int)$loginRate['attempts'] >= 10) { + http_response_code(429); + render('admin/login', ['title'=>'관리자 로그인','error'=>'로그인 시도가 많습니다. 잠시 후 다시 시도해 주세요.','notice'=>'','username'=>$username], 'layouts/admin'); + exit; + } + $authenticated = false; + try { $authenticated = (new AdminAuth())->attempt($username, $password); } catch (Throwable) {} + if ($authenticated) { + unset($_SESSION['admin_login_rate']); + redirect('/admin'); + } + $loginRate['attempts']++; + $_SESSION['admin_login_rate'] = $loginRate; + http_response_code(422); + render('admin/login', ['title'=>'관리자 로그인','error'=>'아이디 또는 비밀번호를 확인해 주세요.','notice'=>'','username'=>$username], 'layouts/admin'); + exit; +} + +if ($path === '/admin/logout' && $method === 'POST') { + verify_csrf(); + AdminAuth::logout(); + redirect('/admin/login'); +} + +if (str_starts_with($path, '/admin')) { + AdminAuth::requireLogin(); + try { $adminRepository = new AdminRepository(); } catch (Throwable) { + http_response_code(503); + render('errors/404', ['title'=>'관리 데이터를 불러올 수 없습니다'], 'layouts/admin'); + exit; + } + + if ($path === '/admin' && $method === 'GET') { + render('admin/dashboard', ['title'=>'대시보드','counts'=>$adminRepository->dashboardCounts()], 'layouts/admin'); + exit; + } + if ($path === '/admin/posts' && $method === 'GET') { + render('admin/posts/index', ['title'=>'글 관리','posts'=>$adminRepository->posts()], 'layouts/admin'); + exit; + } + if ($path === '/admin/posts/new' && $method === 'GET') { + render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>[]], 'layouts/admin'); + exit; + } + if ($path === '/admin/posts/new' && $method === 'POST') { + verify_csrf(); + $postData = collect_post_input(); + $errors = validate_post_input($postData, $adminRepository); + [$image, $imageError] = prepare_post_image($_FILES['image'] ?? null); + if ($imageError !== null) $errors[] = $imageError; + if ($image !== null && $postData['image_alt'] === '') $errors[] = '대표 이미지의 대체 텍스트를 입력해 주세요.'; + if ($errors) { + http_response_code(422); + render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>$errors], 'layouts/admin'); + exit; + } + $newImagePath = null; + try { + if ($image !== null) $newImagePath = store_post_image($image); + $adminRepository->savePostWithImage($postData, null, $newImagePath, $postData['image_alt']); + } catch (Throwable) { + if ($newImagePath !== null) remove_stored_image($newImagePath); + http_response_code(503); + render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>['저장하지 못했습니다. 잠시 후 다시 시도해 주세요.']], 'layouts/admin'); + exit; + } + redirect('/admin/posts'); + } + if (preg_match('#^/admin/posts/(\d+)/edit$#', $path, $adminPostMatch) === 1) { + $postId = (int) $adminPostMatch[1]; + $post = $adminRepository->findPost($postId); + if (!$post) { + http_response_code(404); + render('errors/404', ['title'=>'게시글을 찾을 수 없습니다'], 'layouts/admin'); + exit; + } + if ($method === 'GET') { + render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>[]], 'layouts/admin'); + exit; + } + if ($method === 'POST') { + verify_csrf(); + $postData = collect_post_input(); + $errors = validate_post_input($postData, $adminRepository, $postId); + [$image, $imageError] = prepare_post_image($_FILES['image'] ?? null); + if ($imageError !== null) $errors[] = $imageError; + if (($image !== null || !empty($post['image_path'])) && $postData['image_alt'] === '') $errors[] = '대표 이미지의 대체 텍스트를 입력해 주세요.'; + if ($errors) { + http_response_code(422); + render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>$errors], 'layouts/admin'); + exit; + } + $newImagePath = null; + try { + if ($image !== null) $newImagePath = store_post_image($image); + $result = $adminRepository->savePostWithImage($postData, $postId, $newImagePath, $postData['image_alt']); + foreach ($result['old_paths'] as $oldPath) remove_stored_image((string) $oldPath); + } catch (Throwable) { + if ($newImagePath !== null) remove_stored_image($newImagePath); + http_response_code(503); + render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>['저장하지 못했습니다. 잠시 후 다시 시도해 주세요.']], 'layouts/admin'); + exit; + } + redirect('/admin/posts'); + } + } + if (preg_match('#^/admin/posts/(\d+)/delete$#', $path, $adminDeleteMatch) === 1 && $method === 'POST') { + verify_csrf(); + $adminRepository->deletePost((int) $adminDeleteMatch[1]); + redirect('/admin/posts'); + } + if ($path === '/admin/inquiries' && $method === 'GET') { + render('admin/inquiries/index', ['title'=>'문의 관리','inquiries'=>$adminRepository->inquiries()], 'layouts/admin'); + exit; + } + if (preg_match('#^/admin/inquiries/(\d+)$#', $path, $adminInquiryMatch) === 1) { + $inquiryId = (int) $adminInquiryMatch[1]; + $inquiry = $adminRepository->findInquiry($inquiryId); + if (!$inquiry) { + http_response_code(404); + render('errors/404', ['title'=>'문의를 찾을 수 없습니다'], 'layouts/admin'); + exit; + } + if ($method === 'GET') { + render('admin/inquiries/show', ['title'=>'문의 상세','inquiry'=>$inquiry], 'layouts/admin'); + exit; + } + if ($method === 'POST') { + verify_csrf(); + $allowedStatuses = ['pending','in_progress','replied','closed']; + $status = (string) ($_POST['status'] ?? 'pending'); + $reply = trim((string) ($_POST['reply'] ?? '')); + if (!in_array($status, $allowedStatuses, true) || mb_strlen($reply) > 5000) { + http_response_code(422); + render('admin/inquiries/show', ['title'=>'문의 상세','inquiry'=>$inquiry], 'layouts/admin'); + exit; + } + if ($reply !== '' && $status === 'pending') $status = 'replied'; + $adminRepository->updateInquiry($inquiryId, $status, $reply); + redirect('/admin/inquiries/' . $inquiryId); + } + } + if ($path === '/admin/company') { + $defaults = [ + 'legal_name'=>(string)config('app.legal_name'),'ceo'=>'서선배', + 'phone'=>(string)config('app.contact.phone'),'phone_sub'=>(string)config('app.contact.phone_sub'), + 'fax'=>(string)config('app.contact.fax'),'email'=>(string)config('app.contact.email'), + 'address'=>(string)config('app.contact.address'),'bizno'=>'206-86-70582', + 'business_hours'=>(string)config('app.contact.business_hours'), + ]; + $settingRepository = new SiteSettingRepository(); + $settings = array_merge($defaults, $settingRepository->all()); + if ($method === 'GET') { + render('admin/company', ['title'=>'회사 정보','settings'=>$settings,'errors'=>[]], 'layouts/admin'); + exit; + } + if ($method === 'POST') { + verify_csrf(); + $submitted = []; + foreach (array_keys($defaults) as $key) $submitted[$key] = trim((string)($_POST[$key]??'')); + $errors = []; + if ($submitted['legal_name']==='' || mb_strlen($submitted['legal_name'])>120) $errors[]='법인명을 확인해 주세요.'; + if (!preg_match('/^[0-9+() -]{8,30}$/',$submitted['phone'])) $errors[]='대표번호를 확인해 주세요.'; + if ($submitted['email']!=='' && filter_var($submitted['email'],FILTER_VALIDATE_EMAIL)===false) $errors[]='이메일 형식을 확인해 주세요.'; + if (mb_strlen($submitted['address'])>255) $errors[]='주소를 255자 이내로 입력해 주세요.'; + if ($errors) { + http_response_code(422); + render('admin/company', ['title'=>'회사 정보','settings'=>$settings,'errors'=>$errors], 'layouts/admin'); + exit; + } + $settingRepository->save($submitted); + redirect('/admin/company'); + } + } + + http_response_code(404); + render('errors/404', ['title'=>'관리자 페이지를 찾을 수 없습니다'], 'layouts/admin'); + exit; +} + +http_response_code(404); +render('errors/404', ['title' => '페이지를 찾을 수 없습니다']); + +function collect_post_input(): array +{ + return [ + 'title'=>trim((string)($_POST['title']??'')), + 'slug'=>trim((string)($_POST['slug']??'')), + 'category'=>trim((string)($_POST['category']??'')), + 'excerpt'=>trim((string)($_POST['excerpt']??'')), + 'body'=>trim((string)($_POST['body']??'')), + 'author'=>trim((string)($_POST['author']??'')), + 'status'=>(string)($_POST['status']??'draft'), + 'published_at'=>'', + 'image_alt'=>trim((string)($_POST['image_alt']??'')), + ]; +} + +function validate_post_input(array $data, AdminRepository $repository, ?int $exceptId = null): array +{ + $errors = []; + if ($data['title']==='' || mb_strlen($data['title'])>200) $errors[]='제목을 200자 이내로 입력해 주세요.'; + if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $data['slug'])) $errors[]='slug는 영문 소문자, 숫자, 하이픈으로 입력해 주세요.'; + elseif ($repository->slugExists($data['slug'], $exceptId)) $errors[]='이미 사용 중인 slug입니다.'; + if (!in_array($data['category'], ['네트워크','키폰시스템','CCTV','시공사례'], true)) $errors[]='카테고리를 확인해 주세요.'; + if (mb_strlen($data['excerpt'])>500) $errors[]='목록 요약을 500자 이내로 입력해 주세요.'; + if ($data['body']==='') $errors[]='본문을 입력해 주세요.'; + if ($data['author']==='' || mb_strlen($data['author'])>100) $errors[]='작성자를 확인해 주세요.'; + if (!in_array($data['status'], ['draft','published'], true)) $errors[]='공개 상태를 확인해 주세요.'; + if (mb_strlen($data['image_alt'])>255) $errors[]='이미지 대체 텍스트를 255자 이내로 입력해 주세요.'; + return $errors; +} + +function prepare_post_image(?array $file): array +{ + if ($file === null || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return [null, null]; + if (!isset($file['error'], $file['size'], $file['tmp_name']) || is_array($file['error']) || is_array($file['size']) || is_array($file['tmp_name'])) { + return [null, '이미지 업로드 요청이 올바르지 않습니다.']; + } + if ((int) $file['error'] !== UPLOAD_ERR_OK) return [null, '이미지를 업로드하지 못했습니다. 파일 크기를 확인해 주세요.']; + if ((int) $file['size'] <= 0 || (int) $file['size'] > 8 * 1024 * 1024) return [null, '대표 이미지는 8MB 이하만 등록할 수 있습니다.']; + + $tmpName = (string) $file['tmp_name']; + if (!is_uploaded_file($tmpName)) return [null, '업로드된 이미지 파일을 확인할 수 없습니다.']; + $mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmpName); + $extensions = ['image/jpeg'=>'jpg', 'image/png'=>'png', 'image/webp'=>'webp']; + if (!is_string($mime) || !isset($extensions[$mime])) return [null, 'JPG, PNG, WebP 이미지만 등록할 수 있습니다.']; + $dimensions = getimagesize($tmpName); + if ($dimensions === false || $dimensions[0] < 1 || $dimensions[1] < 1 || $dimensions[0] > 6000 || $dimensions[1] > 6000) { + return [null, '이미지 크기는 최대 6000×6000px까지 등록할 수 있습니다.']; + } + if (($dimensions['mime'] ?? '') !== $mime) return [null, '이미지 파일의 형식이 올바르지 않습니다.']; + + return [['tmp_name'=>$tmpName, 'extension'=>$extensions[$mime]], null]; +} + +function store_post_image(array $image): string +{ + $relativeDirectory = '/uploads/posts/' . date('Y/m'); + $publicRoot = rtrim((string) config('app.public_root', __DIR__), '/'); + $absoluteDirectory = $publicRoot . $relativeDirectory; + if (!is_dir($absoluteDirectory) && !mkdir($absoluteDirectory, 0750, true) && !is_dir($absoluteDirectory)) { + throw new RuntimeException('Upload directory could not be created.'); + } + $relativePath = $relativeDirectory . '/' . bin2hex(random_bytes(20)) . '.' . $image['extension']; + if (!move_uploaded_file($image['tmp_name'], $publicRoot . $relativePath)) { + throw new RuntimeException('Uploaded file could not be moved.'); + } + chmod($publicRoot . $relativePath, 0640); + return $relativePath; +} + +function remove_stored_image(string $path): void +{ + if (!str_starts_with($path, '/uploads/posts/')) return; + $absolutePath = rtrim((string) config('app.public_root', __DIR__), '/') . $path; + if (is_file($absolutePath)) unlink($absolutePath); +} diff --git a/public/uploads/.htaccess b/public/uploads/.htaccess new file mode 100644 index 0000000..983d725 --- /dev/null +++ b/public/uploads/.htaccess @@ -0,0 +1,11 @@ +Options -Indexes + + + + Require all denied + + + Order allow,deny + Deny from all + +