feat:전자계약 문서 페이지 추가 (8개 탭 기반 기술 문서)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-12 13:23:56 +09:00
parent f572865b48
commit e0e6c71701
4 changed files with 779 additions and 0 deletions

View File

@@ -53,4 +53,13 @@ public function send(Request $request, int $id): View|Response
return view('esign.send', ['contractId' => $id]);
}
public function docs(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('esign.docs'));
}
return view('esign.docs');
}
}

Binary file not shown.

View File

@@ -0,0 +1,769 @@
@extends('layouts.app')
@section('title', '전자계약 문서')
@section('content')
<div id="esign-docs-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
<script src="https://unpkg.com/lucide@latest"></script>
@verbatim
<script type="text/babel">
const { useState, useEffect } = React;
// ============================================================
// Tab Definitions
// ============================================================
const TABS = [
{ id: 'overview', label: '개요', icon: 'book-open' },
{ id: 'workflow', label: '워크플로우', icon: 'git-branch' },
{ id: 'architecture',label: '아키텍처', icon: 'layers' },
{ id: 'api', label: 'API 명세', icon: 'code' },
{ id: 'security', label: '보안', icon: 'shield' },
{ id: 'screens', label: '화면 설계', icon: 'monitor' },
{ id: 'operations', label: '운영 가이드', icon: 'settings' },
{ id: 'changelog', label: '변경 이력', icon: 'clock' },
];
// ============================================================
// Shared UI Components
// ============================================================
const Badge = ({ children, color = 'blue' }) => {
const colors = {
blue: 'bg-blue-100 text-blue-700',
green: 'bg-green-100 text-green-700',
yellow: 'bg-yellow-100 text-yellow-700',
red: 'bg-red-100 text-red-700',
gray: 'bg-gray-100 text-gray-600',
purple: 'bg-purple-100 text-purple-700',
indigo: 'bg-indigo-100 text-indigo-700',
};
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors[color] || colors.blue}`}>{children}</span>;
};
const SectionTitle = ({ children }) => (
<h2 className="text-lg font-bold text-gray-900 mb-4 pb-2 border-b border-gray-200">{children}</h2>
);
const SubTitle = ({ children }) => (
<h3 className="text-base font-semibold text-gray-800 mt-6 mb-3">{children}</h3>
);
const CodeBlock = ({ children, title }) => (
<div className="my-3">
{title && <p className="text-xs font-medium text-gray-500 mb-1">{title}</p>}
<pre className="bg-gray-900 text-gray-100 rounded-lg p-4 text-sm overflow-x-auto leading-relaxed"><code>{children}</code></pre>
</div>
);
const InfoCard = ({ title, children, color = 'blue' }) => {
const colors = {
blue: 'border-blue-200 bg-blue-50',
green: 'border-green-200 bg-green-50',
yellow: 'border-yellow-200 bg-yellow-50',
red: 'border-red-200 bg-red-50',
gray: 'border-gray-200 bg-gray-50',
};
return (
<div className={`border rounded-lg p-4 ${colors[color] || colors.blue}`}>
{title && <p className="font-semibold text-sm mb-2">{title}</p>}
<div className="text-sm text-gray-700">{children}</div>
</div>
);
};
const Table = ({ headers, rows }) => (
<div className="overflow-x-auto my-3">
<table className="w-full text-sm border border-gray-200 rounded-lg overflow-hidden">
<thead className="bg-gray-50">
<tr>
{headers.map((h, i) => (
<th key={i} className="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider border-b">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row, ri) => (
<tr key={ri} className="hover:bg-gray-50">
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-2 text-gray-700 whitespace-nowrap">{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
// ============================================================
// Tab 1: Overview
// ============================================================
const OverviewTab = () => (
<div>
<SectionTitle>프로젝트 개요</SectionTitle>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<InfoCard title="프로젝트" color="blue">
<p>SAM E-Sign (전자계약 서명 솔루션)</p>
<p className="text-xs text-gray-500 mt-1">v1.0 / 2026-02-12</p>
</InfoCard>
<InfoCard title="규모" color="green">
<p> 33 파일 (신규 29 + 수정 4)</p>
<p className="text-xs text-gray-500 mt-1">+3,273 라인</p>
</InfoCard>
<InfoCard title="기술 스택" color="gray">
<p>Laravel 11 + React 18 + MySQL 8</p>
<p className="text-xs text-gray-500 mt-1">PDF.js + SignaturePad</p>
</InfoCard>
</div>
<SubTitle>파일 구성</SubTitle>
<Table
headers={['영역', '파일 수', '설명']}
rows={[
['DB 마이그레이션', '4', 'esign_ 접두사 테이블'],
['Models', '4', 'Contract, Signer, SignField, AuditLog'],
['Services', '4', 'Contract, Sign, Pdf, Audit'],
['API Controllers', '2', '계약관리 10 + 서명 6 엔드포인트'],
['Form Requests', '4', 'Store, Configure, Submit, Reject'],
['Mail Templates', '1', 'EsignRequestMail'],
['MNG Controllers', '2', '인증 5화면 + 공개 3화면'],
['MNG Views', '8', 'React 하이브리드'],
['API Routes', '16', '계약 + 서명 라우트'],
['MNG Routes', '8', '화면 + 공개 라우트'],
]}
/>
<SubTitle>기술 스택 상세</SubTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoCard title="Backend" color="gray">
<ul className="space-y-1">
<li>Laravel 11 (PHP 8.3)</li>
<li>MySQL 8.0 (Multi-tenant)</li>
<li>FPDI/FPDF (PDF 합성 - 예정)</li>
<li>Laravel Mail (이메일 발송)</li>
</ul>
</InfoCard>
<InfoCard title="Frontend" color="gray">
<ul className="space-y-1">
<li>React 18 + Babel (CDN, 브라우저 트랜스파일링)</li>
<li>HTMX (네비게이션)</li>
<li>Tailwind CSS (스타일링)</li>
<li>PDF.js (PDF 렌더링)</li>
<li>signature_pad.js (서명 캔버스)</li>
<li>Lucide (아이콘)</li>
</ul>
</InfoCard>
</div>
</div>
);
// ============================================================
// Tab 2: Workflow
// ============================================================
const FlowStep = ({ number, title, desc, color = 'blue' }) => {
const bg = { blue: 'bg-blue-600', green: 'bg-green-600', yellow: 'bg-yellow-500', red: 'bg-red-600', purple: 'bg-purple-600', indigo: 'bg-indigo-600' };
return (
<div className="flex items-start gap-3">
<div className={`flex-shrink-0 w-8 h-8 ${bg[color] || bg.blue} text-white rounded-full flex items-center justify-center text-sm font-bold`}>{number}</div>
<div>
<p className="font-semibold text-gray-900">{title}</p>
<p className="text-sm text-gray-600">{desc}</p>
</div>
</div>
);
};
const WorkflowTab = () => (
<div>
<SectionTitle>계약 생성 ~ 서명 완료 플로우</SectionTitle>
<SubTitle>전체 프로세스 (5단계)</SubTitle>
<div className="space-y-4 mb-8">
<FlowStep number="1" title="계약 생성 (Create)" desc="제목, PDF 업로드, 서명자 정보 입력 → status: draft" color="blue" />
<div className="ml-4 border-l-2 border-gray-200 h-4"></div>
<FlowStep number="2" title="서명 필드 배치 (Configure)" desc="PDF 위에 서명/날인/텍스트 필드 드래그&드롭 배치" color="indigo" />
<div className="ml-4 border-l-2 border-gray-200 h-4"></div>
<FlowStep number="3" title="서명 요청 발송 (Send)" desc="이메일로 서명 링크 발송 → status: pending" color="purple" />
<div className="ml-4 border-l-2 border-gray-200 h-4"></div>
<FlowStep number="4" title="서명 수행 (Sign)" desc="OTP 인증 → 문서 확인 → 서명 제출 → status: partially_signed / completed" color="yellow" />
<div className="ml-4 border-l-2 border-gray-200 h-4"></div>
<FlowStep number="5" title="완료 (Complete)" desc="모든 서명자 서명 완료 → status: completed, 완료 이메일 발송" color="green" />
</div>
<SubTitle>계약 상태 전이</SubTitle>
<div className="bg-gray-50 rounded-lg p-6 my-4">
<div className="flex flex-wrap items-center gap-2 justify-center text-sm">
<Badge color="gray">draft</Badge>
<span className="text-gray-400"></span>
<Badge color="blue">pending</Badge>
<span className="text-gray-400"></span>
<Badge color="yellow">partially_signed</Badge>
<span className="text-gray-400"></span>
<Badge color="green">completed</Badge>
</div>
<div className="flex flex-wrap gap-6 justify-center mt-4 text-xs text-gray-500">
<span>draft <Badge color="gray">cancelled</Badge></span>
<span>pending <Badge color="red">expired</Badge></span>
<span>partially_signed <Badge color="red">rejected</Badge></span>
</div>
</div>
<SubTitle>서명자 상태 전이</SubTitle>
<div className="bg-gray-50 rounded-lg p-6 my-4">
<div className="flex flex-wrap items-center gap-2 justify-center text-sm">
<Badge color="gray">waiting</Badge>
<span className="text-gray-400"></span>
<Badge color="blue">notified</Badge>
<span className="text-gray-400"></span>
<Badge color="yellow">authenticated</Badge>
<span className="text-gray-400"></span>
<Badge color="green">signed</Badge>
</div>
<div className="flex flex-wrap gap-6 justify-center mt-4 text-xs text-gray-500">
<span>notified <Badge color="red">rejected</Badge></span>
<span>authenticated <Badge color="red">rejected</Badge></span>
</div>
</div>
<SubTitle>서명 순서 옵션</SubTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoCard title="counterpart_first (기본)" color="blue">
<p>상대방이 먼저 서명 작성자가 나중에 서명</p>
</InfoCard>
<InfoCard title="creator_first" color="green">
<p>작성자가 먼저 서명 상대방이 나중에 서명</p>
</InfoCard>
</div>
</div>
);
// ============================================================
// Tab 3: Architecture
// ============================================================
const ArchitectureTab = () => (
<div>
<SectionTitle>시스템 아키텍처</SectionTitle>
<SubTitle>DB 스키마 (4 테이블)</SubTitle>
<Table
headers={['테이블', '주요 필드', '용도']}
rows={[
['esign_contracts', 'id, contract_code, status, title, original_file_path, original_file_hash, sign_order, expires_at', '계약 마스터'],
['esign_signers', 'id, contract_id, role, name, email, phone, access_token, otp_code, otp_expires_at, otp_attempts, signature_image_path, sign_session_token', '서명자 정보 & 인증'],
['esign_sign_fields', 'id, contract_id, signer_id, page_number, position_x, position_y, width, height, field_type', '서명 위치 좌표'],
['esign_audit_logs', 'id, contract_id, signer_id, action, ip_address, user_agent, metadata', '감사 추적 (삭제 불가)'],
]}
/>
<SubTitle>서비스 레이어</SubTitle>
<Table
headers={['Service', '주요 메서드', '역할']}
rows={[
['EsignContractService', 'list, stats, create, show, cancel, send, remind, configureFields', '계약 CRUD + 비즈니스 로직'],
['EsignSignService', 'getByToken, sendOtp, verifyOtp, submitSignature, reject', '서명 프로세스 처리'],
['EsignPdfService', 'generateHash, verifyIntegrity, composeSigned*, addAuditPage*', 'PDF 무결성 + 합성 (* stub)'],
['EsignAuditService', 'log, logPublic, getContractLogs', '감사 로그 기록/조회'],
]}
/>
<SubTitle>멀티테넌트 구조</SubTitle>
<InfoCard title="tenant_id 기반 데이터 격리" color="blue">
<ul className="space-y-1">
<li>모든 esign_ 테이블에 <code className="bg-blue-100 px-1 rounded">tenant_id</code> 컬럼 포함</li>
<li><code className="bg-blue-100 px-1 rounded">BelongsToTenant</code> trait 으로 글로벌 스코프 적용</li>
<li>공개 서명 API에서는 <code className="bg-blue-100 px-1 rounded">withoutGlobalScopes()</code> 사용</li>
</ul>
</InfoCard>
<SubTitle>컨트롤러 구조</SubTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoCard title="API (sam-api)" color="gray">
<ul className="space-y-1">
<li><strong>EsignContractController</strong> - 계약 관리 10 엔드포인트</li>
<li><strong>EsignSignController</strong> - 서명 프로세스 6 엔드포인트</li>
</ul>
</InfoCard>
<InfoCard title="MNG (sam-mng)" color="gray">
<ul className="space-y-1">
<li><strong>EsignController</strong> - 인증 필요 화면 5</li>
<li><strong>EsignPublicController</strong> - 공개 서명 화면 3</li>
</ul>
</InfoCard>
</div>
<SubTitle>파일 저장 구조</SubTitle>
<CodeBlock>{`storage/app/esign/
├── contracts/
│ └── {contract_id}/
│ ├── original.pdf # 원본 PDF
│ └── signed.pdf # 서명 완료 PDF (v1.1)
└── signatures/
└── {signer_id}/
└── signature.png # 서명 이미지`}</CodeBlock>
</div>
);
// ============================================================
// Tab 4: API Specification
// ============================================================
const ApiTab = () => (
<div>
<SectionTitle>API 명세 ( 16 엔드포인트)</SectionTitle>
<SubTitle>계약 관리 API (인증 필요 - 10)</SubTitle>
<Table
headers={['Method', 'Path', '설명']}
rows={[
[<Badge color="green">GET</Badge>, '/esign/contracts', '계약 목록 (페이지네이션, 필터)'],
[<Badge color="green">GET</Badge>, '/esign/contracts/stats', '상태별 통계'],
[<Badge color="green">GET</Badge>, '/esign/contracts/{id}', '계약 상세'],
[<Badge color="blue">POST</Badge>, '/esign/contracts', '계약 생성 (multipart/form-data)'],
[<Badge color="blue">POST</Badge>, '/esign/contracts/{id}/fields', '서명 필드 설정'],
[<Badge color="blue">POST</Badge>, '/esign/contracts/{id}/send', '서명 요청 발송'],
[<Badge color="blue">POST</Badge>, '/esign/contracts/{id}/remind', '리마인드 발송'],
[<Badge color="blue">POST</Badge>, '/esign/contracts/{id}/cancel', '계약 취소'],
[<Badge color="green">GET</Badge>, '/esign/contracts/{id}/download', 'PDF 다운로드'],
[<Badge color="green">GET</Badge>, '/esign/contracts/{id}/verify', '무결성 검증'],
]}
/>
<SubTitle>서명 프로세스 API (토큰 기반 - 6)</SubTitle>
<Table
headers={['Method', 'Path', '설명']}
rows={[
[<Badge color="green">GET</Badge>, '/api/esign/sign/{token}', '계약 정보 조회'],
[<Badge color="blue">POST</Badge>, '/api/esign/sign/{token}/otp/send', 'OTP 발송'],
[<Badge color="blue">POST</Badge>, '/api/esign/sign/{token}/otp/verify', 'OTP 검증'],
[<Badge color="green">GET</Badge>, '/api/esign/sign/{token}/document', 'PDF 문서 조회'],
[<Badge color="blue">POST</Badge>, '/api/esign/sign/{token}/submit', '서명 제출'],
[<Badge color="blue">POST</Badge>, '/api/esign/sign/{token}/reject', '서명 거절'],
]}
/>
<SubTitle>요청/응답 예시</SubTitle>
<p className="text-sm font-medium text-gray-700 mb-2">계약 생성 (POST /esign/contracts)</p>
<CodeBlock title="Response 201">{`{
"success": true,
"message": "계약이 생성되었습니다",
"data": {
"id": 1,
"contract_code": "ES-20260212-A1B2C3",
"status": "draft",
"title": "2026년 블라인드 납품 계약서",
"original_file_hash": "e3b0c44298fc1c14...",
"signers": [
{ "id": 1, "role": "creator", "name": "김담당", "status": "waiting" },
{ "id": 2, "role": "counterpart", "name": "이대표", "status": "waiting" }
]
}
}`}</CodeBlock>
<p className="text-sm font-medium text-gray-700 mb-2 mt-4">OTP 검증 (POST /api/esign/sign/{'{token}'}/otp/verify)</p>
<CodeBlock title="Response 200">{`{
"success": true,
"message": "OTP 인증이 완료되었습니다",
"data": {
"sign_session_token": "a3f8b2c1d4e5f6...",
"signer": { "id": 2, "status": "authenticated" }
}
}`}</CodeBlock>
<SubTitle>Enum </SubTitle>
<Table
headers={['카테고리', '값']}
rows={[
['계약 상태', 'draft, pending, partially_signed, completed, expired, cancelled, rejected'],
['서명 순서', 'counterpart_first, creator_first'],
['서명자 역할', 'creator, counterpart'],
['서명자 상태', 'waiting, notified, authenticated, signed, rejected'],
['필드 타입', 'signature, stamp, text, date, checkbox'],
]}
/>
</div>
);
// ============================================================
// Tab 5: Security
// ============================================================
const SecurityTab = () => (
<div>
<SectionTitle>보안 아키텍처</SectionTitle>
<SubTitle>OTP 인증</SubTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<InfoCard title="OTP 스펙" color="yellow">
<ul className="space-y-1">
<li>6자리 숫자 코드</li>
<li>유효 시간: <strong>5</strong></li>
<li>최대 시도: <strong>5</strong></li>
<li>저장: bcrypt 해시 (v1.1 예정, 현재 평문)</li>
</ul>
</InfoCard>
<InfoCard title="인증 프로세스" color="blue">
<ol className="space-y-1 list-decimal list-inside">
<li>서명자 이메일로 OTP 발송</li>
<li>서명자가 6자리 코드 입력</li>
<li>검증 성공 sign_session_token 발급</li>
<li>이후 요청에 세션 토큰 사용</li>
</ol>
</InfoCard>
</div>
<SubTitle>토큰 보안</SubTitle>
<Table
headers={['토큰', '용도', '길이', '만료']}
rows={[
['access_token', '서명 링크', '128자', '계약 만료일까지'],
['sign_session_token', 'OTP 인증 후 세션', '64자', '별도 만료 없음'],
]}
/>
<SubTitle>문서 무결성</SubTitle>
<InfoCard title="SHA-256 해시 검증" color="green">
<ul className="space-y-1">
<li>PDF 업로드 SHA-256 해시 생성 DB 저장</li>
<li><code className="bg-green-100 px-1 rounded">hash_equals()</code> 사용 (타이밍 공격 방지)</li>
<li>서명 완료 PDF 별도 해시 생성</li>
<li>다운로드 무결성 검증 가능</li>
</ul>
</InfoCard>
<SubTitle>감사 로그 (Audit Trail)</SubTitle>
<InfoCard title="삭제 불가 - 법적 증적" color="red">
<p className="mb-2">모든 주요 액션이 자동 기록됩니다:</p>
<div className="flex flex-wrap gap-1">
{['created','sent','viewed','otp_sent','authenticated','signed','rejected','completed','cancelled','reminded','downloaded'].map(a => (
<Badge key={a} color="gray">{a}</Badge>
))}
</div>
<p className="mt-2 text-xs text-gray-500"> 로그에 IP, User-Agent, 메타데이터 포함</p>
</InfoCard>
<SubTitle>법적 준거</SubTitle>
<Table
headers={['항목', '구현']}
rows={[
['전자서명법 제2조', '전자 서명 + 본인 확인 (이메일 OTP)'],
['본인 확인', '이메일 OTP 6자리, 5분, 5회 제한'],
['동의 확인', '체크박스 2개 (문서 확인 + 서명 동의)'],
['위변조 방지', 'SHA-256 해시 + 무결성 검증 API'],
['서명 후 불변', '상태 변경 불가, 감사 로그 영구 보존'],
]}
/>
</div>
);
// ============================================================
// Tab 6: Screens
// ============================================================
const ScreensTab = () => (
<div>
<SectionTitle>화면 설계 (8 화면)</SectionTitle>
<SubTitle>인증 필요 화면 (5)</SubTitle>
<div className="space-y-4 mb-6">
<InfoCard title="1. 대시보드 (/esign)" color="blue">
<ul className="space-y-1">
<li>상태별 통계 카드 (전체, 진행중, 완료, 만료)</li>
<li>계약 목록 테이블 (페이지네이션, 기본 20)</li>
<li>필터: 상태, 검색어, 기간</li>
</ul>
</InfoCard>
<InfoCard title="2. 계약 생성 (/esign/create)" color="blue">
<ul className="space-y-1">
<li>제목 입력 (필수, 최대 200)</li>
<li>PDF 업로드 (최대 20MB)</li>
<li>서명 순서 선택 (상대방 우선 / 작성자 우선)</li>
<li>작성자 & 상대방 정보 (이름, 이메일, 전화번호)</li>
</ul>
</InfoCard>
<InfoCard title="3. 계약 상세 (/esign/{id})" color="blue">
<ul className="space-y-1">
<li>기본 정보 표시 (제목, 코드, 상태, 날짜)</li>
<li>서명자별 상태 카드</li>
<li>감사 로그 타임라인</li>
<li>액션 버튼: 발송, 리마인드, 취소, 다운로드, 무결성 검증</li>
</ul>
</InfoCard>
<InfoCard title="4. 서명 필드 배치 (/esign/{id}/fields)" color="blue">
<ul className="space-y-1">
<li>PDF.js로 문서 렌더링</li>
<li>드래그 & 드롭으로 서명 필드 배치</li>
<li>색상 구분: 작성자(파란색), 상대방(빨간색)</li>
<li>필드 타입: signature, stamp, text, date, checkbox</li>
<li>좌표는 % 단위 (0-100)</li>
</ul>
</InfoCard>
<InfoCard title="5. 서명 요청 발송 (/esign/{id}/send)" color="blue">
<ul className="space-y-1">
<li>발송 체크리스트</li>
<li>서명 순서 최종 확인</li>
<li>발송 버튼</li>
</ul>
</InfoCard>
</div>
<SubTitle>공개 화면 - 서명자용 (3)</SubTitle>
<div className="space-y-4 mb-6">
<InfoCard title="6. OTP 인증 (/esign/sign/{token})" color="green">
<ul className="space-y-1">
<li>계약 정보 표시</li>
<li>OTP 발송 버튼</li>
<li>6자리 입력 필드</li>
<li>카운트다운 타이머 (5)</li>
<li>남은 시도 횟수 표시</li>
</ul>
</InfoCard>
<InfoCard title="7. 서명 수행 (/esign/sign/{token}/sign)" color="green">
<ul className="space-y-1">
<li>3단계 프로세스: 문서 확인 서명 확인</li>
<li>PDF 다운로드 링크</li>
<li>SignaturePad 캔버스 (터치/마우스)</li>
<li>동의 체크박스 2</li>
<li>제출 / 거절 옵션</li>
</ul>
</InfoCard>
<InfoCard title="8. 완료 (/esign/sign/{token}/done)" color="green">
<ul className="space-y-1">
<li>서명 완료 메시지</li>
<li>계약 요약 정보</li>
<li>서명 일시 표시</li>
</ul>
</InfoCard>
</div>
<SubTitle>스토리보드</SubTitle>
<InfoCard title="화면 설계 스토리보드 (PPTX)" color="gray">
<p className="mb-2">8 화면의 상세 UI 와이어프레임이 포함된 스토리보드입니다.</p>
<a href="/docs/esign/esign-storyboard.pptx" download
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
esign-storyboard.pptx 다운로드
</a>
</InfoCard>
</div>
);
// ============================================================
// Tab 7: Operations Guide
// ============================================================
const OperationsTab = () => (
<div>
<SectionTitle>운영 가이드</SectionTitle>
<SubTitle>초기 배포</SubTitle>
<CodeBlock title="배포 절차">{`# 1. 마이그레이션
docker exec sam-api-1 php artisan migrate
# 2. 스토리지 설정
docker exec sam-api-1 mkdir -p storage/app/esign
chmod 775 storage/app/esign
# 3. 라우트 캐시
docker exec sam-api-1 php artisan route:cache
docker exec sam-mng-1 php artisan route:cache`}</CodeBlock>
<SubTitle>환경 변수</SubTitle>
<Table
headers={['변수', '개발 환경', '운영 환경']}
rows={[
['APP_ENV', 'local', 'production'],
['APP_DEBUG', 'true', 'false'],
['MAIL_MAILER', 'log', 'smtp'],
['QUEUE_CONNECTION', 'sync', 'database'],
['FILESYSTEM_DISK', 'local', 'local / s3'],
]}
/>
<SubTitle>모니터링</SubTitle>
<Table
headers={['지표', '확인 방법', '임계치']}
rows={[
['API 응답 시간', 'Nginx 로그', '> 3초 경고'],
['에러율', 'Laravel 로그', '> 10건/시간'],
['디스크 사용량', 'df -h', '> 80% 경고'],
['메일 큐', 'queue:failed', '> 0 알림'],
]}
/>
<SubTitle>백업 전략</SubTitle>
<Table
headers={['대상', '방법', '주기']}
rows={[
['DB (esign_ 테이블)', 'mysqldump', '매일'],
['PDF 파일', 'rsync', '매일'],
['감사 로그', '영구 보존', '삭제 불가'],
]}
/>
<SubTitle>장애 대응</SubTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoCard title="이메일 발송 실패" color="yellow">
<ol className="list-decimal list-inside space-y-1">
<li>Laravel 로그 확인</li>
<li>SMTP 설정 확인</li>
<li> 실패 재시도: <code className="bg-yellow-100 px-1 rounded">queue:retry</code></li>
</ol>
</InfoCard>
<InfoCard title="PDF 업로드 실패" color="yellow">
<ol className="list-decimal list-inside space-y-1">
<li>파일 크기 제한 확인 (20MB)</li>
<li>디스크 공간 확인</li>
<li>storage 디렉토리 권한 확인</li>
</ol>
</InfoCard>
</div>
</div>
);
// ============================================================
// Tab 8: Changelog
// ============================================================
const ChangelogTab = () => (
<div>
<SectionTitle>변경 이력</SectionTitle>
<SubTitle>v1.0.0 (2026-02-12) - 최초 릴리즈</SubTitle>
<InfoCard title="전자계약 서명 솔루션 전체 구현" color="green">
<Table
headers={['저장소', '커밋', '파일', '라인']}
rows={[
['API', '2', '24', '+1,709'],
['MNG', '1', '11', '+1,564'],
['Docs', '8', '10', '-'],
['합계', '11', '45', '+3,273'],
]}
/>
</InfoCard>
<div className="mt-4 space-y-2">
<p className="text-sm text-gray-700"><Badge color="green">API</Badge> 마이그레이션 4, 모델 4, 서비스 4, 컨트롤러 2, FormRequest 4, Mail 1</p>
<p className="text-sm text-gray-700"><Badge color="blue">MNG</Badge> 컨트롤러 2, 8, 라우트 설정</p>
<p className="text-sm text-gray-700"><Badge color="gray">Docs</Badge> 기획서, 기술 설계서, API 명세서, 사용자 매뉴얼, 운영 가이드 8 문서</p>
</div>
<SubTitle>향후 계획</SubTitle>
<div className="space-y-4">
<InfoCard title="v1.1 (계획)" color="blue">
<ul className="space-y-1">
<li>PDF 서명 합성 (FPDI/FPDF)</li>
<li>감사 추적 페이지 PDF 포함</li>
<li>자동 만료 스케줄러</li>
<li> 워커 설정</li>
</ul>
</InfoCard>
<InfoCard title="v1.2 (계획)" color="blue">
<ul className="space-y-1">
<li>SMS OTP (카카오 알림톡)</li>
<li>대량 계약 발송</li>
<li>계약 템플릿 관리</li>
</ul>
</InfoCard>
<InfoCard title="v2.0 (계획)" color="purple">
<ul className="space-y-1">
<li>AWS S3 파일 저장소 전환</li>
<li>공인 인증서 연동</li>
<li>모바일 반응형 UI</li>
<li>다국어 지원</li>
</ul>
</InfoCard>
</div>
</div>
);
// ============================================================
// Tab Content Map
// ============================================================
const TAB_CONTENT = {
overview: OverviewTab,
workflow: WorkflowTab,
architecture: ArchitectureTab,
api: ApiTab,
security: SecurityTab,
screens: ScreensTab,
operations: OperationsTab,
changelog: ChangelogTab,
};
// ============================================================
// Main App
// ============================================================
const App = () => {
const [activeTab, setActiveTab] = useState('overview');
const TabContent = TAB_CONTENT[activeTab];
useEffect(() => {
if (window.lucide) lucide.createIcons();
}, [activeTab]);
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">전자계약 문서</h1>
<p className="text-sm text-gray-500 mt-1">E-Sign 전자계약 서명 솔루션 기술 문서</p>
</div>
<a href="/esign"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium"
hx-boost="false">
대시보드로 돌아가기
</a>
</div>
<div className="flex flex-col lg:flex-row gap-6">
{/* Desktop: Left sidebar tabs */}
<nav className="hidden lg:block w-56 flex-shrink-0">
<div className="sticky top-6 space-y-1">
{TABS.map(tab => (
<button key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors
${activeTab === tab.id
? 'bg-blue-50 text-blue-700 font-semibold'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'}`}>
<i data-lucide={tab.icon} className="w-4 h-4"></i>
{tab.label}
</button>
))}
</div>
</nav>
{/* Mobile: Horizontal scroll tabs */}
<nav className="lg:hidden overflow-x-auto -mx-6 px-6">
<div className="flex gap-1 pb-2 min-w-max">
{TABS.map(tab => (
<button key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm whitespace-nowrap transition-colors
${activeTab === tab.id
? 'bg-blue-50 text-blue-700 font-semibold'
: 'text-gray-600 hover:bg-gray-100'}`}>
<i data-lucide={tab.icon} className="w-4 h-4"></i>
{tab.label}
</button>
))}
</div>
</nav>
{/* Tab content */}
<div className="flex-1 min-w-0 bg-white rounded-xl border border-gray-200 p-6">
<TabContent />
</div>
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById('esign-docs-root')).render(<App />);
</script>
@endverbatim
@endpush

View File

@@ -1397,6 +1397,7 @@
// 화면 라우트
Route::get('/', [EsignController::class, 'dashboard'])->name('dashboard');
Route::get('/create', [EsignController::class, 'create'])->name('create');
Route::get('/docs', [EsignController::class, 'docs'])->name('docs');
Route::get('/{id}', [EsignController::class, 'detail'])->whereNumber('id')->name('detail');
Route::get('/{id}/fields', [EsignController::class, 'fields'])->whereNumber('id')->name('fields');
Route::get('/{id}/send', [EsignController::class, 'send'])->whereNumber('id')->name('send');