Compare commits

460 Commits

Author SHA1 Message Date
김보곤
a14cfaae18 fix: [sound-logo] 시퀀서/AI BGM 상호 배타적 재생 구조 적용
- AI BGM 있으면 시퀀서(수동/프리셋) 음표 제외
- 시퀀서 모드에서는 AI BGM 제외
- TTS 음성은 양쪽 모두 공통 재생
- exportWav도 동일 로직 적용 + 오프라인 컴프레서 추가
2026-03-08 14:53:05 +09:00
김보곤
7fd6b904f6 fix: [sound-logo] 오디오 합성 시 클리핑 소음 방지
- DynamicsCompressor 마스터 노드 추가 (threshold:-6dB, ratio:12)
- 모든 오디오 출력(시퀀서/음성/BGM)을 컴프레서 경유로 변경
- 다중 소스 합산 시 진폭 초과로 인한 디지털 클리핑 방지
2026-03-08 14:47:27 +09:00
김보곤
2f1ea3b369 fix: [sound-logo] Lyria WebSocket 메시지 키 형식 수정
- client_content → clientContent (camelCase)
- music_generation_config 중첩 제거 → musicGenerationConfig 최상위
- playback_control 중첩 제거 → playbackControl 최상위
- WAV 헤더 감지 시 decodeAudioData fallback 추가
2026-03-08 14:38:51 +09:00
김보곤
697560b2de feat: [sound-logo] Lyria BGM WebSocket 디버그 로깅 추가 2026-03-08 14:34:44 +09:00
김보곤
fd4411b04f fix: [sound-logo] 음성 기본 텍스트에 ~ 추가 2026-03-08 14:30:50 +09:00
김보곤
486724d38a fix: [sound-logo] Lyria WebSocket Blob 데이터를 텍스트로 변환 후 JSON 파싱
- Blob 무시 대신 text()로 변환하여 JSON 파싱 시도
- Lyria API가 오디오 청크를 Blob 바이너리 프레임으로 전송하는 경우 대응
2026-03-08 14:27:46 +09:00
김보곤
253067f2b5 fix: [sound-logo] Alpine 표현식 에러 + WebSocket Blob 파싱 에러 수정
- x-text 속성에서 이스케이프된 따옴표 제거 (Blade 빌드 시 깨짐 방지)
- WebSocket onmessage에서 Blob 바이너리 데이터 수신 시 JSON.parse 건너뛰기
2026-03-08 14:19:57 +09:00
김보곤
9b7362fa4f fix: [sound-logo] 음성 카테고리 기반 선택으로 재구성
- 여성/남성/아이 카테고리 탭으로 1차 분류 (성별 확실한 전달)
- 공식 문서 기반 음성 성별 정보 수정 (Gacrux=여성, Sadachbia=남성 등)
- 아이 카테고리: 젊은 음성 + 'young child, high-pitched' Director's Note 지시문
- 스타일 옵션에서 아이/청소년 제거 (카테고리로 이동)
- 프롬프트 형식을 Director's Note 형식으로 개선
2026-03-08 14:12:57 +09:00
김보곤
0e86636354 feat: [sound-logo] 아이 목소리 옵션 + 말하기 속도 조절 추가
- 스타일 옵션에 어린이(5~7세), 초등학생(8~12세), 청소년(13~18세) 추가
- 말하기 속도 슬라이더 추가 (매우느리게~매우빠르게 5단계)
- 속도와 스타일을 TTS 프롬프트 지시문으로 조합하여 Gemini API에 전달
- 음성 목록 여성/남성/중성 순서로 정렬
2026-03-08 14:05:22 +09:00
김보곤
64b3ad2b59 feat: [sound-logo] TTS 음성 옵션 확장 + 잡음 문제 수정
- PCM 디코딩 endianness 수정 (big-endian → little-endian, Gemini TTS는 s16le)
- 16종 음성 선택 옵션 추가 (남성/여성/중성, 성격 설명)
- 9종 발화 스타일 옵션 추가 (밝고 활기차게, 차분하고 신뢰감 등)
- 음성/스타일을 컨트롤러에 전달하여 Gemini API에 적용
- 기본 텍스트: '우리들의 솔루션 쌤, 쌤, 쌤'
2026-03-08 13:58:48 +09:00
김보곤
c993826fdc fix: [sound-logo] UX 개선 - 중앙 toast 안내 시스템 + transport bar 레이아웃 수정
- toast를 화면 중앙에 표시하고 info/warn/error 유형별 색상 분리
- 모든 기능에 조건 미충족 시 가이드 메시지 추가 (음표/음성/배경음악 미생성 안내)
- 에러 발생 시 console 대신 사용자 친화적 toast로 알림
- transport bar 하단 잘림 수정 (height 계산 + margin 보정)
2026-03-08 13:53:24 +09:00
김보곤
0e242bdcc1 feat: [rd] 사운드 로고 생성기 도움말 모달 추가
- 툴바에 ? 아이콘 추가 → 클릭 시 사용법 모달 표시
- 4개 탭(수동/프리셋/AI생성/AI배경음악) 상세 사용법 안내
- 음성 오버레이, 재생/내보내기, 3중 합성 구조 설명 포함
- ESC 키/외부 클릭으로 모달 닫기 지원
2026-03-08 13:43:42 +09:00
김보곤
f8a00c3f8c feat: [rd] AI 배경음악 생성 기능 추가 (Google Lyria RealTime)
- Lyria RealTime WebSocket 연동으로 다중 악기 배경음악 실시간 생성
- BPM, 밀도, 밝기, 스케일 컨트롤 지원
- 시퀀서 + 음성 + 배경음악 3중 합성 (playAll, exportWav)
- 서버 API 키 보호 엔드포인트 (lyria-config)
- 빠른 프롬프트 10종 제공
2026-03-08 13:37:25 +09:00
김보곤
d02c142f65 feat: [rd] 사운드로고/나레이션 AI 토큰 사용량 기록 추가
- RdController: 사운드로고-AI생성, 사운드로고-TTS 토큰 기록
- CmSongController: 나레이션-가사생성, 나레이션-TTS 토큰 기록
- AI 토큰 사용량 UI에 사운드로고/나레이션 카테고리 분류 추가
2026-03-08 12:57:29 +09:00
김보곤
e7f81cb063 fix: [rd] TTS 500 에러 수정 및 AI 응답 파싱 개선
- 짧은 텍스트(4자 미만) TTS 요청 시 따옴표 래핑으로 Gemini TTS 인식률 개선
- TTS API 에러 시 실제 에러 메시지 반환 (기존: 일괄 500)
- AI 생성 temperature 0.9→0.7, maxOutputTokens 2048→4096으로 응답 안정성 개선
- 프롬프트에 name/desc 길이 제한 추가하여 JSON 잘림 방지
2026-03-08 12:51:08 +09:00
김보곤
301369bb37 feat: [sound-logo] TTS 음성 오버레이 기능 추가
- Gemini TTS API 연동 (한국어 Kore 음성)
- 사이드바에 음성 오버레이 컨트롤: 텍스트 입력, 시작 시점, 볼륨
- 재생/WAV 내보내기 시 신스 + 음성 자동 합성
- POST /rd/sound-logo/tts 엔드포인트 추가
- L16 PCM → AudioBuffer 디코더 구현
2026-03-08 12:44:05 +09:00
김보곤
75dbe2910a feat: [sound-logo] Phase 2 AI 어시스트 모드 추가
- Gemini API 연동: 프롬프트 → 음표 시퀀스 JSON 자동 생성
- AI 탭 UI: 프롬프트 입력, 카테고리/길이 선택, 빠른 프롬프트 10종
- AI 결과 미리보기: 음표 시각화, 미리듣기, 시퀀서 로드
- POST /rd/sound-logo/generate 엔드포인트 추가
2026-03-08 12:34:42 +09:00
김보곤
8563d9aa2b feat: [sound-logo] 프리셋 10종 → 50종 확장 + 카테고리 필터 추가
- 8개 카테고리: 기업시그널, 알림/메시지, 상태/피드백, 전환효과, 게임효과, UI인터랙션, 브랜드징글, 방송/미디어
- 카테고리별 필터 탭 UI 추가
2026-03-08 12:27:55 +09:00
김보곤
d81c5f4a6f feat: [rd] 사운드 로고 생성기 Phase 1 MVP 구현
- Web Audio API 기반 사운드 합성 엔진
- 4종 신스(sine/square/triangle/sawtooth) + ADSR 엔벨로프
- 노트 시퀀서 UI (비주얼 바 + 드롭다운 편집)
- 10종 프리셋 (알림, 로고, 시작음, 성공 등)
- WAV 내보내기, JSON import/export, localStorage 저장
2026-03-08 12:15:32 +09:00
김보곤
05af666a4b feat: [design-insight] AI 프롬프트 복사 버튼 추가
- 카드 상세 모달 편집 버튼 옆에 AI 프롬프트 버튼 추가
- 패턴 정보/구성요소/가이드라인을 AI 프롬프트로 변환 후 클립보드 복사
- 복사한 프롬프트를 AI에 붙여넣으면 해당 스타일로 코드 생성 가능
2026-03-08 11:10:54 +09:00
김보곤
1543db684d feat: [design-insight] UI 패턴 50종 → 100종 확장
- 프리셋 템플릿 50개 추가 (51~100번)
- CSS 와이어프레임 50개 추가
- 버튼/토스트/다이얼로그 텍스트 100종으로 수정
2026-03-08 11:02:03 +09:00
김보곤
4c06c81e4a fix: [design-insight] 인기 UI 패턴 20종 → 50종 문구 수정 2026-03-08 10:47:29 +09:00
김보곤
57f53ac01e feat: [design-insight] 50종 UI 패턴 와이어프레임 미리보기 추가
- 로그인/인증 5종: 클래식 로그인, 소셜 SSO, 2FA, 비밀번호 재설정, 회원가입
- 보고서 5종: 인쇄용, 인보이스/견적서, 분석 리포트, 업무 보고서, PDF 리포트
- 대시보드 4종: 위젯, 실시간 모니터링, 멀티 차트, 데이터 시각화
- 목록 3종: 무한 스크롤, 그룹/섹션, 벌크 액션
- 폼 3종: 인라인 편집, 리치 텍스트 에디터, 프로필 카드
- 모달 4종: 확인 다이얼로그, 라이트박스, 알림 센터, 날짜 선택기
- 네비게이션 3종: 메가 메뉴, 모바일 하단바, 다단계 드롭다운
- 기타 3종: 드래그 정렬, 스켈레톤 로딩, 알림 배지
2026-03-08 10:41:23 +09:00
김보곤
1a78f2dc72 feat: [rd] 디자인 인사이트 카드 미리보기 모달 + 와이어프레임 20종
- 카드 클릭 시 미리보기 모달 (좌: 와이어프레임, 우: 정보 패널)
- 패턴 카드 20종 CSS 와이어프레임 자동 생성
- KPI 대시보드, 데이터 테이블, 칸반, Command Palette,
  사이드바, 모달 폼, 설정, 타임라인, 트리 분할뷰,
  온보딩 스테퍼, 토스트, Empty State, 검색 자동완성,
  탭 레이아웃, 카드 그리드, 가격표, 캘린더, 채팅,
  파일 업로드, 브레드크럼
- 미리보기에서 편집 모달로 전환 가능
2026-03-08 10:27:06 +09:00
김보곤
1aa8781bfe feat: [rd] 디자인 인사이트 인기 UI 패턴 20종 프리셋 추가
- 웹서비스 인기 UI 패턴 20종 프리셋 템플릿
- KPI 대시보드, 데이터 테이블, 칸반, Command Palette,
  사이드바, 모달 폼, 설정 페이지, 타임라인, 트리 분할뷰,
  온보딩 스테퍼, 토스트, Empty State, 검색 자동완성,
  탭 레이아웃, 카드 그리드, 가격표, 캘린더, 채팅,
  파일 업로드, 브레드크럼
- 빈 상태 및 내보내기 메뉴에서 불러오기 버튼 제공
- 각 패턴별 구성 요소, 가이드라인, 참고 서비스 포함
2026-03-08 10:12:11 +09:00
김보곤
a6779e0031 fix: [rd] 디자인 인사이트 'SAM ERP' → 'SAM' 용어 수정 2026-03-08 10:07:36 +09:00
김보곤
ce055542a5 feat: [rd] 디자인 인사이트 도움말 모달 추가
- ? 버튼 클릭 시 7개 탭 도움말 모달 표시
- 개요, 툴바, 카드 유형, 뷰 모드, 사이드바, 단축키, 워크플로우
- 각 기능별 상세 설명 및 빠른 시작 가이드
2026-03-08 10:02:37 +09:00
김보곤
d9c808b928 feat: [rd] 디자인 인사이트 메뉴 Phase 1 MVP 구현
- GET /rd/design-insight 라우트 + 컨트롤러 추가
- Alpine.js 단일 파일 SPA (localStorage 기반)
- 4종 카드: 레퍼런스, 분석(CRAP), 패턴, Before/After
- 3종 뷰: 보드, 갤러리, 리스트
- Ctrl+V 클립보드 이미지 붙여넣기
- 프로젝트 CRUD, 태그/카테고리 필터, 검색
- JSON 내보내기/가져오기
2026-03-08 09:55:36 +09:00
김보곤
a38c017c63 fix: [planning-design] 이미지 블록 업로드를 더블클릭으로 변경
- 단일 클릭 시 드래그 이동 중 파일 창이 뜨는 문제 해결
- @click.stop → @dblclick.stop 변경
2026-03-08 09:23:14 +09:00
김보곤
f120273160 feat: [planning-design] 작업 영역 극대화 (패널 접기/펼치기)
- 좌측 메뉴트리 패널 접기/펼치기 토글 버튼 추가
- Description 패널 접기/펼치기 토글 바 추가
- 사이드바 접힘 시 스토리보드 페이지 폭 1100→1400px 자동 확장
- sb-editor padding 24→12px 축소
2026-03-08 09:19:18 +09:00
김보곤
5e0f1a6373 feat: [planning-design] 사이드바 접기/펼치기 버튼 추가
- 좌측 패널 탭 바에 접기(<<) 버튼 추가
- 사이드바 접힘 시 좌측 가장자리에 펼치기(>) 버튼 표시
- 캔버스 작업 공간 극대화 지원
2026-03-08 09:08:21 +09:00
김보곤
280bfddbd3 fix: [planning-design] 블록 서식(글자색/크기/굵기 등) 자식 요소에 상속 적용
- 자식 요소(.sb-blk-text, .sb-blk-heading 등)에 하드코딩된 color가 있어 부모 스타일 무시됨
- CSS attribute selector로 부모에 style이 설정된 경우 color/font-size/font-weight/font-style/text-align inherit 적용
2026-03-08 01:26:07 +09:00
김보곤
dfbbd3a1a0 feat: [planning-design] 블록 서식 툴바 + 우클릭 컨텍스트 메뉴 추가
- 블록 선택 시 Notion 스타일 플로팅 서식 툴바 표시
- 글자색, 배경색, 글자 크기, 굵게, 기울임, 정렬 설정
- 앞/뒤로 보내기 (z-index), 서식 초기화
- 우클릭 컨텍스트 메뉴: 복제/잘라내기/삭제/서식/레이어
- 서브메뉴로 글자색/배경색 직접 선택 가능
- 블록별 style 속성 저장 (localStorage 영속)
- HTML 내보내기/인쇄에 서식 반영
2026-03-08 01:22:06 +09:00
김보곤
ac5ae6eb05 feat: [planning-design] 좌표 기반 인쇄 기능 추가 + HTML 내보내기 블록 좌표 배치 개선 2026-03-08 00:51:12 +09:00
김보곤
8ff84e7f94 feat: [planning-design] Description 패널 리사이즈 + 번호 뱃지 마커 블록 (드래그&드롭/툴바) 2026-03-08 00:41:32 +09:00
김보곤
f4131df0ce fix: [planning-design] Ctrl+X 후 Ctrl+Z 복구 안 되는 문제 수정 2026-03-08 00:34:11 +09:00
김보곤
95cd217cdc feat: [planning-design] Ctrl+X 잘라내기 기능 추가 (단일/다중) 2026-03-08 00:31:06 +09:00
김보곤
ff373c719c fix: [planning-design] 올가미 선택 동작 안 되는 문제 수정 2026-03-08 00:29:01 +09:00
김보곤
7785dfed98 feat: [planning-design] 올가미(마퀴) 다중 선택 + 그룹 이동/복사/삭제 기능 추가 2026-03-08 00:26:26 +09:00
김보곤
20e5ab784e feat: [planning-design] 메뉴/캔버스 경계 드래그 리사이즈 기능 추가 2026-03-08 00:20:42 +09:00
김보곤
08cc866afa fix: [planning-design] 블록 툴바를 단위업무 상단으로 이동 2026-03-08 00:17:05 +09:00
김보곤
a27d9921b1 fix: [planning-design] placeholder 색상 더 옅게 + italic 스타일 적용 2026-03-07 23:54:19 +09:00
김보곤
78c8f3f876 feat: [planning-design] 스토리보드 페이지 복사 기능 추가 2026-03-07 23:51:53 +09:00
김보곤
063d8c61e4 feat: [planning-design] 스토리보드 블록 Undo/Redo 기능 추가 (Ctrl+Z/Y) 2026-03-07 23:49:34 +09:00
김보곤
5271072e20 feat: [planning-design] 블록 Ctrl+C/V 복사 붙여넣기 및 Delete 삭제
- Ctrl+C: 선택된 블록 클립보드 복사
- Ctrl+V: 클립보드 블록 붙여넣기 (24px 오프셋)
- Delete/Backspace: 선택된 블록 삭제
- 연속 Ctrl+V 시 오프셋 누적으로 겹침 방지
2026-03-07 23:43:16 +09:00
김보곤
056f7f99f3 feat: [planning-design] 블록 자유 배치 캔버스 (PPT 스타일)
- 블록을 드래그하여 자유롭게 위치 이동
- 오른쪽/아래/대각선 리사이즈 핸들로 크기 조절
- 더블클릭으로 편집 모드 진입
- 그리드 도트 배경으로 위치 인지 용이
- 선택 시 크기 표시 (w × h)
- 블록 기본 크기를 유형별로 최적화
- 템플릿 삽입 시 자동 세로 배치
2026-03-07 23:40:14 +09:00
김보곤
1bb134020c fix: [planning-design] 템플릿 패널 잘림 현상 수정
- position: absolute → fixed로 변경 (부모 overflow 영향 제거)
- 버튼 위치 기준으로 JS 동적 좌표 계산
- 화면 경계 밖 방지 (좌/우/하단 overflow 체크)
2026-03-07 23:34:37 +09:00
김보곤
3659bef743 feat: [planning-design] 스토리보드 블록 템플릿 시스템 추가
- 기본 프리셋 9종: 검색+목록, 상세폼, CRUD, 대시보드, 결재폼,
  탭 레이아웃, 팝업/모달, 로그인, 빈 페이지
- 내 템플릿 저장/삽입/삭제 (localStorage 영구 보관)
- 템플릿 검색 필터, 프리셋/커스텀 탭 분리
- 현재 페이지 블록을 한 번에 템플릿으로 저장하여 재활용
2026-03-07 23:29:18 +09:00
김보곤
6ce6c853b3 feat: [planning-design] 스토리보드 블록 편집기 구현
- 노션 스타일 블록 기반 화면 설계 편집기
- 15종 블록: 제목(H1/H2), 텍스트, 테이블, 콜아웃, 체크리스트,
  코드, 버튼, 입력필드, 셀렉트, 카드, 뱃지, 이미지, 구분선
- 드래그 앤 드롭 블록 순서 변경
- 블록 복제, 위/아래 이동, 삭제 지원
- HTML 내보내기에 블록 렌더링 반영
2026-03-07 23:19:16 +09:00
김보곤
0622fc2a34 feat: [planning-design] 메뉴 트리 편집 모달 UI 추가
- JSON prompt 방식 → 트리구조 모달 UI로 개선
- 상위/하위 메뉴 추가, 삭제, 이름 편집 지원
- 드래그 앤 드롭으로 메뉴 순서 변경 가능
- 접기/펼치기 토글 지원
2026-03-07 23:07:17 +09:00
김보곤
708cef2ec7 feat: [planning-design] 스토리보드 뷰 통합 완성
- loadProject()에서 sb 데이터 복원 추가
- newProject()에서 sb 초기화 추가
- init()에서 sbInitPages() 호출 추가
2026-03-07 22:55:39 +09:00
김보곤
d7edb52573 fix: [rd] 기획디자인 연결선 화살표 제거, 단순 곡선으로 변경 2026-03-07 22:32:09 +09:00
김보곤
94d19af290 feat: [rd] 기획디자인 연결선 삭제 + 스페이스바 패닝 기능 추가
- 연결선 클릭 선택 → Delete/Backspace로 삭제
- 우클릭 컨텍스트 메뉴에 '연결선 삭제' 항목 추가
- 스페이스바 누른 채 마우스 드래그로 캔버스 이동 (Figma/FigJam 방식)
- 패닝 중 커서 grab/grabbing 변경
2026-03-07 22:28:40 +09:00
김보곤
6f11a08a9f fix: [rd] 기획디자인 연결선 렌더링 수정 (SVG namespace 문제 해결)
- Alpine.js <template x-for>가 SVG 내부에서 path 요소 생성 불가 문제
- SVG 요소를 createElementNS로 직접 생성하는 renderConnections() 도입
- x-effect + _connTick 카운터로 노드 이동/연결 변경 시 자동 리렌더
2026-03-07 22:22:35 +09:00
김보곤
178e4e22aa feat: [rd] 기획디자인 7대 기능 추가 (칸반/모달/체크리스트/담당자/필터/검색/리스트뷰)
- 칸반 보드: 상태별 컬럼 드래그앤드롭으로 상태 전환
- 노드 상세 모달: 더블클릭으로 전체 편집 (Notion 스타일)
- 체크리스트: 모달 내 하위 작업 관리, 진행률 프로그레스 바
- 담당자/마감일 필드: 노드별 배정, 기한 초과 빨간 표시
- 필터 바: 상태/우선순위/유형/텍스트 필터링 (Ctrl+F)
- 리스트/테이블 뷰: 정렬 가능한 전체 노드 목록
- autoSave toast 제거 (UX 방해 요소)
2026-03-07 22:16:52 +09:00
김보곤
64ab20becf feat: [rd] 기획디자인 플래닝 캔버스 페이지 추가
- 연구개발 > 기획디자인 메뉴 라우트/컨트롤러/뷰 추가
- Alpine.js 기반 캔버스 도구 (노드 배치, 연결, 줌/팬)
- 16종 노드 타입 (기획/분석/구조/산출물 카테고리)
- 타임라인/플로우 뷰 모드, 프로젝트 저장/불러오기
- 실행취소/재실행, 키보드 단축키 지원
2026-03-07 22:06:06 +09:00
김보곤
e38ef0f1d5 fix: [approval] 결재선 인원 목록에서 미배정/퇴사/외주 사용자 제외
- 부서 미배정(department_id NULL) 사용자 목록에서 제외
- 코드브릿지엑스(tenant_id=1) 테넌트에서 영업팀(외주) 제외
2026-03-07 21:53:22 +09:00
김보곤
6c683c7d4e feat: [approval] 결재선 요약 카드 가로 2분할 (결재선 | 참조선 분리 표시)
- 기안 작성 페이지 결재선 요약 영역을 좌우 2분할 레이아웃으로 변경
- 좌측: 결재선 (결재/합의 카드), 우측: 참조선 (참조 카드)
- 참조자가 없으면 결재선만 전체 너비로 표시
2026-03-07 20:57:57 +09:00
김보곤
a032b1a11e fix: [menus] 메뉴 관리 페이지 기본 행 표시 500개 고정
- 공통 pagination_per_page 쿠키 대신 menu_per_page 전용 쿠키 사용
- 다른 페이지에서 200개로 설정해도 메뉴 관리는 항상 500개 기본
2026-03-07 19:50:47 +09:00
김보곤
0dab993508 feat: [help] 도움말 > 바로빌 연동 가이드 페이지 추가
- 7탭 구성: 전체 구조, 초기 설정, 세금계산서, 계좌/카드, 홈택스 연동, 카카오톡/SMS, 메뉴 맵
- BarobillGuideController + HX-Redirect 패턴 적용
- 테넌트 필수 설정, 바로빌↔SAM 연동 구조, FAQ 포함
2026-03-07 19:02:53 +09:00
김보곤
67694b926f feat: [help] 도움말 > 연차휴가/근태관리 페이지 추가
- 7탭 구성: 전체 흐름도, 연차 발생/계산, 휴가 신청/결재, 근태 기록, 연차촉진제도, 급여 연동, 메뉴 맵
- AttendanceGuideController + HX-Redirect 패턴 적용
- 근로기준법 기반 연차 발생 기준, 촉진 타임라인, 급여 연동 로직 포함
2026-03-07 18:50:51 +09:00
김보곤
d9ad2e801b feat: [help] 도움말 > 회계동작원리 페이지 추가
- 6탭 구성: 전체 흐름도, 일상 업무, 월간 업무, 세금/부가세, 정산/결산, 메뉴 맵
- AccountingGuideController + HX-Redirect 패턴 적용
- 중소기업 회계담당자를 위한 SAM 재무/회계 가이드
2026-03-07 18:36:08 +09:00
김보곤
d2620ddd22 feat: [claude-code] 벤치마크 이름 hover 시 설명 툴팁 추가 (SWE-bench, GPQA, ARC-AGI 등) 2026-03-07 18:25:04 +09:00
김보곤
a7287638c4 fix: [claude-code] 모델 비교 카드 날짜 뱃지 inline style로 변경 (Tailwind bg-opacity 미적용 문제) 2026-03-07 18:22:45 +09:00
김보곤
28a9c8075d fix: [claude-code] 모델 비교 카드 날짜 텍스트 색상 수정 (흰배경에 흰글자 → 명시적 text-white) 2026-03-07 18:21:01 +09:00
김보곤
9c3b9951b0 fix: [claude-code] 발전과정 리서치 데이터 보강 및 오류 수정
- Opus 4.1 (2025.08.05) 모델 추가 (SWE 74.5%, GPQA 80.9%)
- Haiku 4.5 SWE-bench 73.3% 벤치마크 추가
- Opus 4.5 가격 수정 ($15/$75 → $5/$25)
- Opus 4 GPQA 수정 (79.6% → 76.9%), 출시일 수정 (09 → 05)
- Claude Code GA 날짜 정정 (2025.04 → 2025.05.22)
- Claude Code $1B 런레이트 매출 달성 정보 추가
- SWE-bench 성장 바 차트에 Opus 4.1, Haiku 4.5 추가
2026-03-07 18:17:55 +09:00
김보곤
5c900b618b feat: [claude-code] 발전과정 페이지 대폭 강화 - 모델 비교 탭 추가
- 새 '모델 비교' 탭: 각 모델별 특징 카드, 벤치마크 바 차트, 핵심 스펙
- Opus 4.6/4.5, Sonnet 4.6/4.5, 4 Opus/Sonnet, 3.7/3.5, Haiku 4.5 상세
- SWE-bench, GPQA Diamond, ARC-AGI 2, OSWorld 벤치마크 수치 반영
- Opus 4.6 vs Sonnet 4.6 직접 비교표 추가
- 모델 진화 탭: 세대별 비교표 10개 모델로 확장, SWE-bench 성장 바 차트
- 타임라인 탭: Opus 4.5(2025.11) 노드 추가, 정확한 출시일 반영
- 히어로 수치 업데이트 (9세대 모델, SWE-bench 80.8%)
2026-03-07 18:15:18 +09:00
김보곤
ffe02b3224 feat: [claude-code] 발전과정 페이지 추가 (타임라인/핵심혁신/모델진화/생태계) 2026-03-07 18:03:34 +09:00
김보곤
8784b81f1b feat: [china-tech] 중국 AI기술 발전과정 페이지 추가 2026-03-07 17:57:44 +09:00
김보곤
e272f16357 feat: [database] codebridge DB connection 적용 (merge 후 재적용)
- 78개 MNG 전용 모델에 $connection = 'codebridge' 재적용
- config/database.php codebridge connection 포함
2026-03-07 11:28:47 +09:00
김보곤
32e9f317d5 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-manage into develop 2026-03-07 11:28:23 +09:00
김보곤
ca50f65124 feat: [database] codebridge DB 분리 - 118개 MNG 전용 테이블 connection 설정
- config/database.php에 codebridge connection 추가
- 78개 MNG 전용 모델에 $connection = 'codebridge' 설정
  - Admin (15): PM, 로드맵, API Explorer
  - Sales (16): 영업파트너, 수수료, 가망고객
  - Finance (9): 법인카드, 자금관리, 홈택스
  - Barobill (12): 은행/카드 동기화 관리
  - Interview (1), ESign (6), Equipment (2)
  - AI (3), Audit (3), 기타 (11)
2026-03-07 11:27:18 +09:00
2d820cd395 feat: 제품검사 요청서 template(ID 66) 시더 및 모델 보완
- ProductInspectionRequestTemplateSeeder: 변경사유 width null→1fr 수정
- DocumentTemplateSection: description fillable 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:30:15 +09:00
f5bc4fcb19 feat: [문서인쇄] print.blade.php에 rendered_html 스냅샷 우선 출력 추가
- rendered_html 있으면 스냅샷 그대로 출력
- 없으면 기존 템플릿 기반 동적 렌더링 fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:30:15 +09:00
fe420a3cd7 feat: 문서 스냅샷 출력 + 절곡 전용 렌더링
- show.blade.php: rendered_html 우선 출력 로직 추가 (스냅샷 모드)
- show.blade.php: 스냅샷 없으면 기존 동적 렌더링 fallback
- DocumentController: inspectionData 추출하여 view 전달
- partials/bending-inspection-data: inspection_data 스냅샷 기반 렌더링
- partials/bending-worklog: 절곡 작업일지 전용 렌더링

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:30:15 +09:00
김보곤
2e97b824cd feat: [hr] 연차촉진 관리 페이지 추가
- LeavePromotionController: 대상자 목록 조회 + 일괄 통지 발송
- LeaveService: getPromotionCandidates(), sendPromotionNotices() 메서드 추가
- 통지 현황 추적 (미발송/1차 발송/완료)
- 일괄 선택 + 결재 문서 자동 생성 + 상신
2026-03-07 00:46:10 +09:00
김보곤
617c89a33f fix: [approval] 연차사용촉진 통지서 Employee 모델 속성 수정
- departments->first() → department? (BelongsTo 단수 관계)
- $emp->name → $emp->display_name
- $emp->position → $emp->position_key
- $emp->id → $emp->user_id
- LeaveService에 department eager load 추가
2026-03-07 00:33:36 +09:00
김보곤
2a1e72a15e feat: [approval] 연차사용촉진 통지서 1차/2차 양식 추가
- 1차 통지서: 직원 선택, 연차 현황(발생/사용/잔여), 제출기한, 법적 문구
- 2차 통지서: 직원 선택, 잔여연차, 회사 지정 휴가일(다건), 법적 문구
- create/edit/show 통합 완료
- 미리보기/인쇄 기능 포함
2026-03-07 00:28:58 +09:00
김보곤
fc5af2734a fix: [approval] 공문서 양식에 샘플 데이터 기본값 추가 2026-03-06 23:53:29 +09:00
김보곤
77c1012d23 feat: [approval] 공문서 양식 추가
- 공문서 전용 폼/조회 파셜 추가
- create/edit/show 페이지에 공문서 통합
- 문서번호, 수신, 참조, 제목, 본문, 붙임 입력
- 발신자 정보 테넌트에서 자동 로드
- 미리보기/인쇄 기능 (공문서 형식)
2026-03-06 23:38:55 +09:00
김보곤
7ab410e454 feat: [approval] 견적서 양식 추가
- 견적서 전용 폼/조회 파셜 추가
- create/edit/show 페이지에 견적서 통합
- Alpine.js 동적 품목 테이블 (자동 세액 계산)
- 공급자 정보 테넌트에서 자동 로드
- 미리보기/인쇄 기능
2026-03-06 23:21:49 +09:00
김보곤
9f4c79b5d2 feat: [approval] 이사회의사록 양식 추가
- 이사회의사록 전용 폼(_board-minutes-form.blade.php) 생성
- 이사회의사록 읽기전용 뷰(_board-minutes-show.blade.php) 생성
- Alpine.js 의안/서명란 동적 추가/삭제 기능
- 테넌트 정보에서 회사명/대표자 자동 채움
- create/edit/show 페이지 통합
- 미리보기/인쇄 기능 포함
- 인사/근태 카테고리에 배치
2026-03-06 23:00:22 +09:00
김보곤
1ccf97ce6b feat: [approval] 위임장 양식 추가
- 위임장 전용 폼(_delegation-form.blade.php) 생성
- 위임장 읽기전용 뷰(_delegation-show.blade.php) 생성
- create/edit/show 페이지에 위임장 통합
- 미리보기/인쇄 기능 포함
- 인사/근태 카테고리에 배치
2026-03-06 22:25:00 +09:00
김보곤
810c1f67dd feat: [finance] 경조사비 관리 페이지 추가
- 거래처 경조사비 관리대장 CRUD (등록/수정/삭제)
- 축의/부조 구분, 부조금(현금/계좌이체/카드), 선물(종류/금액) 관리
- 연도별 필터, 구분별 필터, 거래처/내역 검색
- 통계 카드 (총건수, 총금액, 부조금 합계, 선물 합계, 축의/부조 비율)
- CSV 내보내기
- 라우트: /finance/condolence-expenses
2026-03-06 21:38:41 +09:00
김보곤
466aafdb01 feat: [approvals] 결재선/참조선 2영역 분리 UI
- 결재선 에디터를 결재선(결재/합의)과 참조선으로 분리
- 좌측 인원 목록에 '결재' / '참조' 두 버튼 제공
- 결재선: 드래그 정렬, 결재/합의 유형 선택
- 참조선: 칩(태그) 형태로 표시, 상신 즉시 열람 가능
- show 페이지에 참조자 목록 표시 추가
- getStepsData()에서 결재선+참조선 합산하여 기존 API 호환 유지
2026-03-06 21:18:36 +09:00
김보곤
bcd35895d1 fix: [approvals] 사용인감계 create 폼/미리보기 인감비교 형식 반영
- create.blade.php: buildSealUsagePreviewHtml 인감비교 레이아웃 적용
- create.blade.php: saveApproval formContent에서 seal_type/remarks 제거, attachment_desc 추가
- _seal-usage-form.blade.php: 인감비교 2열 레이아웃 + 용도/제출처/첨부서류 필드
- _seal-usage-show.blade.php: 읽기전용 인감비교 레이아웃
2026-03-06 21:09:27 +09:00
김보곤
2e4cccc19e fix: [approvals] 사용인감계 미리보기를 인감비교 형식으로 통일
- edit/show 페이지의 buildSealUsagePreviewHtml을 법인인감|사용인감 비교 레이아웃으로 교체
- 구 테이블 형식(인감종류/비고 필드) 제거
- create 페이지와 동일한 확약문구, 일자 포맷, 회사정보 레이아웃 적용
2026-03-06 21:03:15 +09:00
김보곤
c850172f5b feat: [approvals] 사용인감계 양식 추가
- 증명서 카테고리에 사용인감계(seal_usage) 양식 등록
- 입력 폼: 사용일자, 인감종류, 용도, 제출처, 비고
- 회사 정보 자동 로드 (테넌트 정보 기반)
- 미리보기/인쇄 기능 (원본 DOCX 유사 레이아웃)
- create/edit/show 3개 페이지 모두 지원
2026-03-06 20:48:01 +09:00
김보곤
81157a150a feat: [org-chart] 부서 숨기기 상태 DB 저장
- departments.options JSON 컬럼에 orgchart_hidden 플래그 저장
- 숨기기/복원 시 API 호출하여 영구 저장
- 페이지 로드 시 DB에서 숨김 상태 복원
2026-03-06 20:24:51 +09:00
김보곤
8c8fd5f61f fix: [org-chart] 대표이사 미배치 제외 및 숨긴 부서 연결선 제거
- 대표이사/사장/회장 등 임원직 미배치 목록에서 제외
- ceoName과 일치하는 직원도 미배치에서 제외
- 숨긴 부서의 상위 연결선(vertical connector) 제거
- rootDepts getter에서도 숨긴 부서 필터링
2026-03-06 20:20:17 +09:00
김보곤
9fd72e49e2 feat: [org-chart] 부서 숨기기 기능 추가
- 부서 헤더 더블클릭 시 숨기기 버튼 표시
- 숨긴 부서와 하위 부서 트리에서 제거, 연결선 자동 조정
- 숨겨진 부서 패널에서 눈 아이콘 클릭으로 복원
2026-03-06 20:15:56 +09:00
김보곤
a12ee886a5 fix: [org-chart] 연결선 'ㄱ'자 형상 수정 및 드롭존 숨김 처리
- 수평 연결선 위치: top:12px → top:0 (수직선과 정확히 접합)
- 하위 부서 드롭존: 기본 숨김, 드래그 시에만 표시
2026-03-06 20:09:55 +09:00
김보곤
197e6e6652 fix: [org-chart] 부서 드래그 정렬 버그 수정 및 계층 이동, 직책 표시 개선
- SortableJS+Alpine 충돌 해결: 수동 DOM 렌더링으로 전환
- 부서 드래그로 다른 부서 하위로 이동 가능 (parent_id 변경)
- 순환 참조 방지 (자기 자신/하위로 이동 불가)
- 재귀 렌더링으로 무제한 depth 지원
- 직책이 이름 앞에 표시 ("사원 김보곤")
- 빈 하위 드롭존: 드래그 시에만 표시
2026-03-06 20:05:32 +09:00
김보곤
e1fc78ada1 feat: [org-chart] 조직도 최상단 노드 색상 수정 및 부서 드래그 정렬 기능 추가
- 최상단 회사 노드: Tailwind gradient → inline style로 변경 (글씨 안보이는 문제 수정)
- 부서 카드 드래그 앤 드롭 정렬: SortableJS handle 기반
- 1단계/2단계 부서 모두 드래그 정렬 가능
- sort_order 변경 즉시 서버 저장 (reorder-depts API)
- 부서 헤더에 드래그 아이콘 추가
2026-03-06 19:50:36 +09:00
김보곤
df72d241fb feat: [rd] 조직도 클래식 하향식 트리 형태로 개편
- 회사(대표이사) → 1단계 부서 → 2단계 → 3단계 하향식 트리 구조
- 부서 간 수직/수평 연결선으로 계층 시각화
- 미배치 직원 패널을 상단 접이식으로 변경
- 부서 카드 클릭 시 하위 부서 펼침/접기
- drag & drop 배치 기능 유지
2026-03-06 19:42:21 +09:00
김보곤
399813a16f fix: [rd] 조직도 Blade 템플릿 ParseError 수정
- @json 내 화살표 함수를 컨트롤러로 이동
- Blade 컴파일러와 배열 구문 충돌 해결
2026-03-06 19:37:06 +09:00
김보곤
774a35e097 feat: [rd] 조직도 관리 화면 추가
- SortableJS 기반 drag & drop 부서 배치 UI
- 미배치 직원 패널 + 부서 트리 (3단계 계층 지원)
- 직원 배치/해제 API 엔드포인트
- 실시간 저장 및 인원수 표시
2026-03-06 19:34:52 +09:00
김보곤
ebb10b5c47 fix: [approvals] 근태신청 기간 표시에서 T 제거 (2026-03-12T14:00 → 2026-03-12 14:00) 2026-03-06 17:58:59 +09:00
김보곤
f51427bcce fix: [approvals] 사직서 주민번호 마스킹 제거 2026-03-06 17:48:39 +09:00
김보곤
862e980809 fix: [approvals] 근태신청 종료일도 종료일시(datetime-local)로 변경 2026-03-06 17:46:38 +09:00
김보곤
adb0cde573 feat: [approvals] 근태신청 시작일에 시간 선택 기능 추가
- 근태신청(attendance_request) 선택 시 시작일 input을 datetime-local로 전환
- 라벨도 '시작일' → '시작일시'로 변경
- 휴가/사유서는 기존 date 유지
2026-03-06 17:44:27 +09:00
김보곤
457576f2f5 fix: [approvals] 위촉증명서 PDF 상단 여백 축소 (전체 위치 상향) 2026-03-06 17:40:56 +09:00
김보곤
e963b5a2dc fix: [approvals] 위촉증명서 주민번호 마스킹 제거 2026-03-06 17:35:44 +09:00
김보곤
25f811bcb6 fix: [approvals] 재직/경력증명서 주민번호 전체 표시 (마스킹 제거)
- 재직증명서: 주민번호 뒷자리 ****** 마스킹 제거, 전체 표시
- 경력증명서: 주민등록번호 필드 추가 (폼/조회/미리보기/PDF)
- EmploymentCertService: maskedResident 로직 제거
- CareerCertService: resident_number 반환 추가, PDF 행 추가
2026-03-06 15:58:51 +09:00
김보곤
48613ecc70 feat: [approvals] 구매품의서에 지급방법(법인카드/계좌이체) 선택 추가 2026-03-06 15:38:51 +09:00
김보곤
8f494270d9 feat: [approvals] 비용정산품의서 지급방법을 각 행별 선택으로 변경
- 하단 일괄 radio 제거, 각 내역행에 지급방법 select 추가
- tfoot에 법인카드/개인선지출 합계표 추가
- 조회 화면에도 지급방법 컬럼 및 합계표 반영
2026-03-06 15:28:18 +09:00
김보곤
147274ca14 fix: [sidebar] 즐겨찾기 섹션에 메뉴 뱃지 표시 동기화 2026-03-06 15:21:51 +09:00
김보곤
efd8d96156 feat: [sidebar] 사이드바 메뉴 즐겨찾기 기능 추가
- MenuFavorite 모델 생성 (menu_favorites 테이블)
- SidebarMenuService에 즐겨찾기 CRUD 메서드 추가
- MenuFavoriteController 생성 (toggle/reorder API)
- 사이드바 상단에 즐겨찾기 섹션 표시
- 메뉴 아이템에 별 아이콘 추가 (hover 시 표시, 토글)
- 최대 10개 제한, 리프 메뉴만 대상
2026-03-06 14:34:27 +09:00
김보곤
ecd813c0b7 fix: [approval] 결재선 인원 목록에서 퇴사자 제외
- tenant_user_profiles.employee_status = 'resigned' 필터 추가
- search(), list() 두 엔드포인트 모두 적용
2026-03-06 14:31:48 +09:00
김보곤
4e2893be92 fix: [approvals] 지출품의서 '사용일자' → '지출일자' 라벨 변경 2026-03-06 13:42:48 +09:00
김보곤
94664898a5 feat: [approvals] 양식 선택 2단계 구조 (분류 → 양식)
- 1단계: 분류 선택 (일반/인사·근태/증명서/품의/재무)
- 2단계: 해당 분류 내 양식만 필터링하여 표시
- 분류별 아이콘 표시 (📄📜📋💰👤)
- edit 화면에서 기존 양식의 분류 자동 선택
2026-03-06 13:18:44 +09:00
김보곤
e78aef47e5 feat: [approvals] 전체 양식 설명 카드 추가
- 업무기안서, 휴가신청, 근태신청, 사유서 등 8개 양식 설명 추가
- 재직/경력/위촉증명서, 사직서 포함 전체 14종 양식 설명 완비
- 양식별 고유 아이콘/색상으로 시각적 구분
2026-03-06 13:08:09 +09:00
김보곤
7bbfc9dab5 feat: [approvals] 양식 선택 시 설명 카드 표시
- 지출결의서/품의서 5종 선택 시 우측에 설명 카드 노출
- 드롭다운 30% + 설명 카드 70% 레이아웃
- 양식별 아이콘/색상/설명 텍스트 (사전승인 vs 사후보고 등)
- create/edit 동일 적용
2026-03-06 12:54:53 +09:00
김보곤
b35b352f19 feat: [approvals] 품의서 5종 분리 (지출/계약체결/구매/출장/비용정산)
- 기존 단일 품의서(purchase_request)를 5가지 전문 양식으로 분리
- pr_expense: 지출품의서 (항목/금액/비고)
- pr_contract: 계약체결품의서 (계약상대방/기간/금액/조건)
- pr_purchase: 구매품의서 (품목/수량/단가/납품정보)
- pr_trip: 출장품의서 (일정표/경비내역)
- pr_settlement: 비용정산품의서 (사용일자/항목/지급방법)
- Alpine.js 단일 컴포넌트로 5종 동적 전환
- show/create/edit 모두 pr_ prefix 코드 자동 감지
2026-03-06 11:40:50 +09:00
김보곤
befba7e2ae feat: [approvals] 품의서 양식 추가
- 품의서(purchase_request) 전용 폼/뷰 partial 추가
- 지출결의서 기반, 지출방법(카드/계좌) 제거, 구매목적 필드 추가
- 테이블: 품명/수량/단가/금액/업체명/비고 (수량×단가 자동계산)
- 희망 납기일, 요청부서/요청자, 첨부파일 지원
- create/edit/show 모두 분기 처리
2026-03-06 11:28:15 +09:00
김보곤
2d327a8300 fix: [approvals] 위촉증명서 테이블 2열 행 텍스트 넘침 수정
- table-layout:fixed + colgroup(18%/32%/18%/32%)로 열 너비 고정
- td에 white-space:nowrap 추가하여 텍스트 줄바꿈 방지
- th width 고정값 제거 → colgroup 비율로 제어
- "위촉(재직)기간" → "위촉기간"으로 라벨 축소
- padding 18px→14px, font 16px→15px로 미세 조정
2026-03-06 10:56:07 +09:00
김보곤
6ebaa756a6 fix: [approvals] 사직서 레이아웃 개선 - A4 용지 내 안정된 수직 배분
- HTML: @page A4 설정, cert-page wrapper(padding 100px), th/td 16px 18px, font 16px
- PDF: 상단여백 40mm+Ln20, rowHeight 8→12, 본문 10→12pt, 문구 12→14pt, 회사 14→16pt
- 섹션 간격 대폭 확대 (테이블↔문구 30mm, 신청인↔회사 30mm)
- create/show 동일 적용
2026-03-06 10:40:30 +09:00
김보곤
74b37a287e fix: [approvals] 경력증명서 증명문구 재직/퇴직 분기 처리
- 퇴직일 있음: "위 사람은 당사에 재직(근무) 하였음을 증명합니다."
- 퇴직일 없음(현재 재직): "위 사람은 당사에서 재직(근무) 하고 있음을 증명합니다."
- HTML 미리보기(create/show) + PDF 모두 적용
2026-03-06 10:35:42 +09:00
김보곤
4b478b4e05 fix: [approvals] 재직증명서 레이아웃 개선 - A4 용지 내 수직 배분 조정
- HTML 미리보기: @page A4 설정, cert-page wrapper, padding/font-size 증가
- PDF(TCPDF): 상단여백 추가, 섹션간격 확대, rowHeight 8→10, 본문 10→11pt
- 증명문구/날짜 12→14pt, 회사명 14→16pt
- create/show 동일 적용
2026-03-06 10:25:55 +09:00
김보곤
71fce456b5 fix: [approvals] 위촉증명서 레이아웃 개선 - 테이블 행 높이/글자크기 증가
- HTML 미리보기: th/td padding 16px 18px, font-size 16px, th width 140px
- PDF(TCPDF): rowHeight 8→12, 본문 폰트 10→12, 증명문구/날짜 12→14, 회사명 14→16
- create/show 동일하게 적용
2026-03-06 10:19:04 +09:00
김보곤
5d7eb57578 feat: [document] 양식 디자이너(Block Builder) Phase 2 - 블록 런타임 렌더러
- BlockRendererService: view/edit/print 3모드 렌더링 지원
  - edit 모드: 폼 필드(input/select/textarea/checkbox) 생성
  - view 모드: 읽기 전용 데이터 표시
  - print 모드: 인쇄 최적화 레이아웃
- 데이터 바인딩: block.binding → document_data.field_key 매핑
- 체크박스 그룹: 콤마 구분 값으로 저장/복원
- 테이블 셀 편집: tbl_{blockId}_r{row}_c{col} 키로 EAV 저장
- edit.blade.php: 블록 빌더 서식 분기 (blockFormContainer)
- show.blade.php: 블록 빌더 조회 모드 분기
- DocumentController: renderBlockHtml() 메서드 추가
2026-03-06 10:14:39 +09:00
김보곤
27790861c2 fix: [approvals] 위촉증명서 인쇄/PDF A4 레이아웃 수직 배분 개선
- 인쇄 CSS: @page A4 적용, 상단 padding 100px로 확대
- HTML 미리보기: 제목/테이블/증명문구/날짜/서명 간격 확대
- PDF: 상단 여백 및 섹션 간 Ln 값 증가 (A4 수직 균등 배분)
2026-03-06 10:08:55 +09:00
김보곤
a34d23fd59 fix: [approvals] 재직증명서 양식에 대표자명/회사주소 누락 수정
- 재직증명서 폼/제출/미리보기/PDF에 ceo_name, company_address 추가
- tenants 테이블에서 가져온 회사 정보를 모든 기안 양식에 통일 적용
- 경력/위촉/사직서는 이미 정상 처리, 재직증명서만 누락되어 있었음
2026-03-06 09:19:32 +09:00
김보곤
7ffa8952fe feat: [approval] 사직서 양식 추가
- ResignationService 생성 (정보 조회 + PDF 생성)
- 사직서 전용 폼/조회 파셜 추가
- create/show 블레이드에 사직서 JS 로직 통합
- 컨트롤러 resignationInfo/resignationPdf 메서드 추가
- API 라우트 resignation-info, resignation-pdf 등록
2026-03-06 00:13:17 +09:00
김보곤
0445748b32 feat: [approval] 위촉증명서 기안/조회/PDF 기능 추가
- AppointmentCertService: 사원 위촉정보 조회 + TCPDF PDF 생성
- 기안 작성 폼: 사원 선택, 인적/위촉/발급 정보, 미리보기
- 상세 조회: 읽기전용 렌더링 + 미리보기/PDF 다운로드
- API: appointment-cert-info, appointment-cert-pdf 엔드포인트
2026-03-05 23:57:42 +09:00
김보곤
1fef5f16d9 fix: [approval] 전용 폼 선택 시 제목을 항상 양식명으로 설정 2026-03-05 23:46:38 +09:00
김보곤
2bf13cc886 feat: [approval] 경력증명서 기안/조회/PDF 기능 추가
- CareerCertService: 사원 경력정보 조회 + TCPDF PDF 생성
- 기안 작성 폼: 사원 선택, 인적/경력/발급 정보, 미리보기
- 상세 조회: 읽기전용 렌더링 + 미리보기/PDF 다운로드
- API: career-cert-info, career-cert-pdf 엔드포인트
2026-03-05 23:41:20 +09:00
김보곤
7a277c6986 feat: [corporate-card] 카드분리 기능 추가
- 결제 내역 수정 모달에 카드분리 버튼 추가
- 카드별 배분금액 직접 입력 UI
- 균등 배분 / 비율 배분 / 해제 버튼
- 배분 합계 검증 (일치해야 저장 가능)
- card_splits 데이터 JSON 저장 (기존 items 확장)
- cardDeductions 로직: card_splits 우선 적용, 없으면 기존 비율 배분
2026-03-05 23:19:19 +09:00
김보곤
159a7a9331 fix: [sidebar] 메뉴 검색 결과 텍스트 색상 가독성 개선 2026-03-05 22:11:27 +09:00
김보곤
08cd48405e fix: [rd] 중대재해처벌법 진단 개요 너비 20% 확대 및 체크리스트 전체 너비 사용 2026-03-05 22:08:24 +09:00
김보곤
cf7ffb69f5 feat: [rd] 중대재해처벌법 실무 점검 대시보드 추가
- 6개 카테고리 34개 점검항목 인터랙티브 체크리스트
- Chart.js 도넛/막대 차트 실시간 통계
- React 기반 SPA 대시보드
2026-03-05 21:57:00 +09:00
김보곤
0e34da74eb feat: [juil] 업무 Workflow 분기형 UI 구현
- 입찰 참여 기업 / 수의계약 기업 두 경로로 분기
- A경로: 영업 → 견적서 작성 → 입찰 참여 → 수주/계약
- B경로: 영업 → 견적서 작성 → 수주/계약 (입찰 생략)
- 분기/합류 시각적 연결선으로 표현
- 수주/계약 이후 공통 프로세스로 합류
2026-03-05 21:27:50 +09:00
김보곤
02e03b1044 fix: [juil] 업무 Workflow 프로세스 순서 수정
- 영업/수주 → 영업 (수주는 견적 이후로 이동)
- 순서 변경: 영업 → 입찰 참여 → 견적서 작성 → 수주/계약
- 입찰 참여를 선택적 단계로 변경 (소규모/수의계약 시 생략 가능)
- 분기 표시: 대형/공공 vs 소규모/수의계약 경로 안내
2026-03-05 21:18:44 +09:00
김보곤
b19eb8c217 fix: [sidebar] 메뉴 검색 닫기 시 스크롤 위치가 초기화되는 문제 수정
- 검색 닫기 시 매칭된 메뉴 위치로 스크롤 유지
- 부모 그룹 자동 펼침으로 해당 메뉴 바로 확인 가능
2026-03-05 21:05:55 +09:00
김보곤
bb2a3f730b fix: [bank-account] 보유계좌관리 테이블에 테넌트ID 열 추가 2026-03-05 21:01:02 +09:00
김보곤
03f48dfe89 fix: [approval] 지출결의서 출금계좌 목록 테넌트 필터링 수정
- BankAccount 글로벌 스코프 의존 → 명시적 tenant_id 필터로 변경
- CorporateCard와 동일한 패턴으로 통일
2026-03-05 20:28:55 +09:00
김보곤
945305b54b feat: [tenant] 테넌트 편집에 인쇄용 회사 표시명 필드 추가
- 테넌트 편집 페이지에 '인쇄용 회사명' 입력 필드 추가
- 저장 시 tenant_settings 테이블에 display_company_name 저장
- 재직증명서 등 문서에서 표시명 우선 적용
2026-03-05 20:20:29 +09:00
김보곤
bfe1167f20 feat: [cm-song] 나레이션 제작 시 자동 저장
- 저장 버튼 제거, 제작 완료 시 서버에 자동 저장
- 자동 저장 상태 표시 (저장 중.../자동 저장됨/저장 실패)
- 불필요한 나레이션은 목록에서 삭제하는 방식으로 변경
2026-03-05 20:13:33 +09:00
김보곤
65774ab93d feat: [juil] 업무 Workflow 상세 모달 추가
- 각 워크플로우 단계 클릭 시 상세 업무 모달 표시
- 서브플로우 4단계 (단계별 아코디언 펼침)
- Input/Output, 담당자, 소요시간, TIP 정보 포함
- 미니 서브플로우 다이어그램으로 단계 간 이동 가능
2026-03-05 19:59:48 +09:00
김보곤
ac094c5833 fix: [routes] /settings 리다이렉트 제거 (기존 시스템 설정 route 충돌 방지) 2026-03-05 19:48:17 +09:00
김보곤
3c75b97873 fix: [routes] /settings → /tenant-settings 리다이렉트 추가 2026-03-05 19:44:18 +09:00
김보곤
561883676e feat: [juil] 업무 Workflow 플로우차트 메뉴 추가
- 주일기업 기획 하위 '업무 Workflow' 메뉴 추가
- 11단계 업무처리과정 인터랙티브 플로우차트 구현
- 각 단계 클릭 시 상세정보(담당부서, 필요서류, SAM 연동) 표시
2026-03-05 19:41:26 +09:00
김보곤
21f930a52f feat: [tenant-settings] 회사 표시명 설정 추가
- 테넌트 설정에 '인쇄용 회사명' 입력 필드 추가
- 재직증명서 등 문서 인쇄 시 표시명 우선 적용
- 비워두면 기본 company_name 사용
2026-03-05 19:36:44 +09:00
김보곤
b16eb343a0 refactor: [approval] 재직증명서 DOCX 생성을 제거하고 content JSON 저장 + PDF 다운로드 방식으로 변경
- 상신 시 DOCX 생성 API 호출 제거, content JSON만 저장
- show 페이지에 PDF 다운로드 버튼 추가
- TCPDF 기반 PDF 생성 (기존 Pretendard 한글 폰트 활용)
- EmploymentCertService에서 generateDocx/createFileRecord 제거
2026-03-05 19:29:20 +09:00
김보곤
08d7409435 fix: [approval] 재직증명서 DOCX 생성을 PhpWord 직접 생성으로 변경
- 외부 템플릿 파일(employment_cert.docx) 의존성 제거
- PhpWord로 테이블/텍스트 직접 생성하여 서버 배포 시 템플릿 누락 문제 해결
2026-03-05 19:17:21 +09:00
김보곤
3f6dfd7251 feat: [approval] 재직증명서 미리보기 및 인쇄 기능 추가
- create/edit: 미리보기 버튼 + 모달 (실제 증명서 양식 레이아웃)
- show: 증명서 미리보기 버튼 + 모달 (content 데이터 기반)
- 인쇄 버튼으로 새 창에서 바로 인쇄 가능
2026-03-05 19:13:54 +09:00
김보곤
531e9ec0ca fix: [tenant] TenantScope에 session selected_tenant_id fallback 추가
- users 테이블에 tenant_id 컬럼이 없어 글로벌 스코프 미작동
- session('selected_tenant_id') fallback으로 테넌트 필터링 정상화
- 결재 양식 등 모든 BelongsToTenant 모델에 영향
2026-03-05 19:08:07 +09:00
김보곤
956f57d5d6 feat: [approval] 재직증명서 기안 기능 추가
- EmploymentCertService: 사원 정보 조회, DOCX 생성, 파일 레코드 생성
- API 엔드포인트: cert-info/{userId}, generate-cert-docx
- _certificate-form: 인적사항/재직사항/발급정보 입력 폼
- _certificate-show: 재직증명서 읽기전용 표시 파셜
- create/edit/show에 employment_cert 양식 분기 처리
- phpoffice/phpword 패키지 추가
2026-03-05 18:53:42 +09:00
김보곤
d698996e31 feat: [approval] 결재함/참조함/완료함 페이지 사이즈 선택, 체크박스 선택삭제 기능 추가
- 기안함과 동일한 UI 패턴 적용
- 페이지당 표시 건수 선택 (15/50/100/200/500)
- 전체선택/개별선택 체크박스 + 선택삭제
- 슈퍼관리자 영구삭제 컬럼 추가
2026-03-05 17:46:11 +09:00
김보곤
e19487683c feat: [approval] 기안함 페이지 사이즈 선택, 체크박스 선택삭제 기능 추가
- 페이지당 표시 건수 선택 (15/50/100/200/500, 기본 15)
- 첫 번째 열 체크박스 추가 (전체선택/개별선택)
- 선택삭제 버튼 및 bulk-delete API 엔드포인트 추가
2026-03-05 17:23:14 +09:00
김보곤
30078e5e86 fix: [hr] 잔여연차 탭에서 영업팀+제외 사원 필터링 적용 2026-03-05 17:08:41 +09:00
김보곤
4247a60aa2 fix: [approval] 영구삭제 File 모델 네임스페이스 오류 수정
- App\Models\File → App\Models\Commons\File 수정
2026-03-05 16:57:36 +09:00
김보곤
8100f889f5 feat: [hr] 근태관리 영업팀 및 제외 사원 필터링 적용
- 근태 목록/통계/요약/초과근무에서 영업팀+제외 사원 제외
- 근태관리 부서 드롭다운에서 영업팀 제외
- 활성 사원 목록(드롭다운)에서 영업팀+제외 사원 제외
2026-03-05 16:54:21 +09:00
김보곤
8239f03592 fix: [approval] 영구삭제 시 첨부파일/하위문서 정리 및 에러 로깅 추가
- 첨부파일(files 테이블) soft delete 처리
- 하위 문서(parent_doc_id) 참조 해제
- DB 트랜잭션으로 원자성 보장
- catch 블록에 report() 추가로 에러 로깅
2026-03-05 16:51:00 +09:00
김보곤
579a6caf39 feat: [payroll] 엑셀 export에 추가공제 항목 동적 열 포함
- 전 사원의 deductions JSON에서 고유 항목명 수집
- 개인별 추가공제 항목을 동적 열로 확장 출력
- 추가공제 열 헤더 보라색, 데이터 영역 연보라 배경 구분
- 추가공제 없는 사원은 해당 열 0 표시
2026-03-05 16:28:58 +09:00
김보곤
a112ace148 feat: [approval] 기안함 작성자 열 추가 및 슈퍼관리자 영구삭제 기능
- 기안함 테이블에 작성자 열 추가
- 슈퍼관리자: 전체 기안문서 조회 + 영구삭제 버튼
- forceDestroy API 엔드포인트 추가 (연관 Leave/Steps 함께 삭제)
- 기안함에서 휴가신청 시 Leave 자동 생성 로직 추가
2026-03-05 16:21:48 +09:00
김보곤
d59a651fb9 fix: [leave] 휴가신청 탭 기본 종료일을 연말로 변경
- 미래 날짜 휴가가 기본 필터에서 제외되는 문제 수정
- date_to 기본값: today → endOfYear
2026-03-05 16:06:16 +09:00
김보곤
404342f750 feat: [dashboard] 비본사/영업팀/소속없음 사용자는 주간 날씨만 표시
- HQ(코드브릿지엑스) 테넌트가 아닌 경우 날씨 카드만 노출
- 소속 부서가 없거나 영업팀만 소속인 경우 날씨 카드만 노출
- Quick Actions, 달력, 일정 모달은 본사 직원만 표시
2026-03-05 15:59:59 +09:00
김보곤
5adedb35bb feat: [approval] 기안함 휴가신청 → 휴가관리 연동
- 기안함에서 휴가/근태신청/사유서 양식 선택 시 전용 입력 폼 표시
- 양식코드별 유형 필터링 (leave/attendance_request/reason_report)
- saveApproval()에서 content에 구조화된 데이터 포함
- handleApprovalCompleted()에서 Leave 없을 시 자동 생성
- createLeaveFromApproval() 메서드 추가
2026-03-05 15:57:36 +09:00
김보곤
778961c9f0 feat: [business-card] 명함신청 화면에 제작 일정 안내 배너 추가
- 그라데이션 배경 + SVG 장식 배너 디자인
- 신청 마감/제작 소요/배송 기간 일정 안내
- 컬러 아이콘 활용 시각적 구분
2026-03-05 15:27:59 +09:00
김보곤
8671b218d1 fix: [receivable] 거래처별 요약 거래건수에 입금 건수 포함되는 오류 수정
- transactionCount가 차변+대변 전체를 카운트하여 실제 매출 건수의 2배로 표시됨
- 차변(매출 발생) 건수만 카운트하도록 수정
2026-03-05 15:26:53 +09:00
김보곤
301afcfc95 feat: [hr] 휴가관리 잔여연차 탭에도 재직상태 필터 추가 2026-03-05 15:22:51 +09:00
김보곤
be35f7ba49 feat: [hr] 연차잔여 탭에 재직상태 필터 추가 (전체/재직자/퇴직자)
- 필터 기본값: 재직자 (active + leave)
- 퇴직자 선택 시 resigned만 표시
- 전체 선택 시 모든 상태 표시
2026-03-05 15:16:54 +09:00
김보곤
5f81e5f356 feat: [hr] 사원관리 영업팀 제외 및 강제 제외 기능 추가
- 영업팀 포함 부서 사원 기본 제외 (외부직원)
- json_extra.is_excluded 플래그로 강제 제외/복원 토글
- 필터에 '제외 사원 표시' 체크박스 추가
- 제외 사원 시각적 구분 (주황 배경, 제외 뱃지)
2026-03-05 15:16:15 +09:00
김보곤
c255bb001a feat: [layout] Remix Icon CDN 추가
- R&D 페이지에서 ri-* 아이콘이 렌더링되지 않던 문제 해결
2026-03-05 14:59:32 +09:00
김보곤
56ab5d86b6 fix: [cm-song] 나레이션 제작 TTS 오디오 재생 버튼 미표시 수정
- TTS try-catch 블록 누락 수정 (JS 구문 오류 해결)
- audioReady display:flex 명시적 설정 (hidden 제거 후 레이아웃 보장)
2026-03-05 14:56:42 +09:00
김보곤
4cf208e2d8 refactor: [rd] CM송 → 나레이션 명칭 변경 + 결과 자동 스크롤
- 모든 UI 텍스트 CM송 → 나레이션으로 변경
- 버튼: 나레이션 제작
- 제작 시 결과 패널로 자동 스크롤
- 프롬프트, 다운로드 파일명, 저장 메시지 모두 변경
2026-03-05 14:51:09 +09:00
김보곤
b04b30f076 fix: [rd] CM송 저장 시 tenant_id를 session에서 가져오도록 수정 2026-03-05 14:48:04 +09:00
김보곤
b21f1bc0c0 fix: [rd] CM송 제작 입력 필드 기본값 설정 2026-03-05 14:46:08 +09:00
김보곤
975dd84564 feat: [rd] CM송 길이 슬라이더, 다운로드, 저장/목록 기능 추가
- 10~60초 5초 간격 길이 선택 슬라이더
- 음성 파일 WAV 다운로드
- 생성 결과 DB 저장 + 목록/상세/삭제 관리
- CmSong 모델 + tenant 스토리지 연동
2026-03-05 14:37:00 +09:00
김보곤
69f837ef99 feat: [rd] AI CM송 제작 기능 추가
- Gemini API 기반 CM송 가사 생성 + TTS 음성 생성
- 연구개발 대시보드에 CM송 제작 카드 추가
- 서버사이드 API 프록시로 API 키 보호
2026-03-05 14:13:41 +09:00
김보곤
3464787a4c feat: [approval] 반려 이력 관리 기능 추가
- rejection_history JSON 컬럼으로 반려 이력 누적 저장
- 재상신 시 반려자, 사유, 일시를 이력에 기록
- 상세 페이지에 반려 이력 섹션 표시 (빨간 테두리)
- 수정 페이지에 이전 반려 이력 표시 (주황 배경)
2026-03-05 13:50:45 +09:00
김보곤
d328055f83 feat: [approval] 기안함/완료함/대기함에 재상신 구분 열 추가
- resubmit_count 필드로 재상신 횟수 추적
- 반려 후 재상신 시 카운트 증가
- 보라색 뱃지로 재상신/재상신(N차) 표시
2026-03-05 13:06:30 +09:00
김보곤
121fec76e0 fix: [approval] 완료함 확인 상태 컬럼 추가 및 개별 읽음 처리로 변경
- 일괄 읽음 처리 제거 → 상세 페이지 열람 시에만 개별 읽음 처리
- 확인 컬럼 추가: 확인전(주황) / 확인(회색) 뱃지 표시
- 미확인 행 배경 하이라이트(주황) + 제목 볼드 처리
- 기안자 본인 문서만 확인 상태 표시, 타인 문서는 - 표시
2026-03-05 12:53:49 +09:00
8298b4271e chore: [infra] Slack 알림 채널 분리 — product_infra → deploy_mng
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:37:43 +09:00
김보곤
d48a38eaf6 feat: [approval] 완료함 미읽음 알림 뱃지 기능 추가
- approvals 테이블에 drafter_read_at 컬럼 추가 (API 마이그레이션)
- 승인/반려/전결 완료 시 drafter_read_at = null 설정
- getBadgeCounts()에 completed_unread 카운트 추가
- 사이드메뉴 완료함에 미읽음 뱃지 표시 (주황색)
- 완료함 페이지 진입 시 일괄 읽음 처리
- 상세 페이지 열람 시 개별 읽음 처리
2026-03-05 11:36:58 +09:00
김보곤
c734a23b30 fix: [approval] 결재서명란 테이블 크기 확대 (150% 너비, 130% 글씨)
- 셀 패딩 10px→16px, min-width 64px→96px
- 기본 폰트 12px→15px, 도장 32px→42px
- 결재 헤더 13px→16px, 이름/날짜 비례 확대
2026-03-05 11:29:18 +09:00
김보곤
24f8bfeb94 feat: [approval] 결재서명란 테이블 추가 (전통 결재 양식)
- 문서 상세 우측 상단에 결재서명란 테이블 배치
- 작성자 + 결재자 컬럼, 직급/이름/서명/날짜 표시
- 승인/반려/보류/전결 상태별 도장 아이콘
- 기존 원형 타임라인 결재 진행 제거, 결재 의견만 유지
2026-03-05 11:23:32 +09:00
김보곤
35080c252c fix: [approval] 거래처 검색 키보드 방향키 내비게이션 버그 수정
- moveDown/moveUp 시 debounce 타이머 클리어하여 search 재실행 방지
2026-03-05 11:16:01 +09:00
김보곤
0e3eb24dd0 fix: [approvals] 완료함에 기안자 본인의 완료 문서도 표시
- 기존: 결재자로 처리한 문서만 조회
- 수정: 내가 기안한 완료/반려/회수 문서 + 결재자로 처리한 문서 모두 조회
2026-03-05 11:07:56 +09:00
김보곤
31ac46fe21 fix: [approvals] 거래처 선택 후 드롭다운이 다시 열리는 문제 수정
- 선택 시 selected 플래그 설정 + blur로 포커스 해제
- onInput/onFocus에서 selected 상태면 검색 차단
- 다시 직접 타이핑 시 selected 해제되어 검색 재개
2026-03-05 11:05:13 +09:00
fd017a9e34 fix: [document] document.data null 참조 오류 수정
- DocumentController: resolveAndBackfillBasicFields에서 data null-safe 처리
- show.blade.php: $docData 변수로 일괄 치환 (클로저 포함 전체 12곳)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:03:52 +09:00
김보곤
491426fc3e fix: [approvals] 거래처 검색 드롭다운을 순수 DOM으로 body에 직접 렌더링
- x-teleport 대신 document.body.appendChild로 드롭다운 생성
- position:fixed + z-index:99999로 모든 레이아웃 위에 표시
- mousedown으로 blur 전 선택 처리
2026-03-05 10:58:00 +09:00
김보곤
e1299d5f25 fix: [approvals] 거래처 검색 드롭다운이 레이아웃에 가려지는 문제 수정
- x-teleport로 body에 렌더링하여 overflow 영향 제거
- position:fixed + getBoundingClientRect로 정확한 위치 계산
2026-03-05 10:55:11 +09:00
김보곤
adc54ffeba feat: [approvals] 지출결의서 업체명에 거래처 검색 기능 추가
- 업체명 input을 거래처 검색 자동완성으로 교체
- 기존 trading_partners 검색 API 활용 (/barobill/tax-invoice/search-partners)
- 거래처명/사업자번호로 검색, 드롭다운에서 선택
- 키보드 탐색 지원 (위/아래 화살표, Enter, Escape)
- vendor_id, vendor_biz_no 추가 저장
2026-03-05 10:52:49 +09:00
김보곤
c653618ecc fix: [approvals] 불러오기 버튼을 양식 선택 옆으로 이동 2026-03-05 10:46:13 +09:00
김보곤
ac8f16de59 feat: [approvals] 지출결의서 불러오기 기능 추가
- 기안 작성 시 '불러오기' 버튼으로 기존 지출결의서 불러오기
- 지출결의서 이력 API 엔드포인트 추가 (/expense-history)
- 선택한 지출결의서의 내용을 새 폼에 복사 (날짜는 오늘로 초기화)
2026-03-05 10:26:55 +09:00
김보곤
0011681683 fix: [approvals] 지출부서 기본값 경리부로 변경, 복지카드 옵션 삭제 2026-03-05 10:20:15 +09:00
김보곤
cfae574a35 fix: [approvals] 결재일자 기본값을 현재일자로 설정 2026-03-05 10:17:44 +09:00
김보곤
b083d1561f feat: [approvals] 지출결의서 양식 필드 추가
- 지출형식에 '자동이체 출금' 라디오버튼 추가
- 세금계산서 종류에 '없음' 옵션 추가
- 작성일자 옆에 '결재일자' 날짜 입력 필드 추가
- 저장/수정 로직(getFormData)에 신규 필드 반영
- 읽기전용 표시(_expense-show)에도 반영
2026-03-05 10:15:35 +09:00
김보곤
7c38790801 fix: [leaves] 휴가관리 삭제/영구삭제 함수 누락 수정 2026-03-04 23:09:14 +09:00
김보곤
8cda77ea17 fix: [approval] 파일 업로드 시 display_name 누락 오류 수정 2026-03-04 21:31:57 +09:00
김보곤
f2556aae61 feat: [approval] 지출형식별 내역 테이블 동적 전환
- 법인카드: 지급은행/계좌/예금주 → 결제카드 컬럼 (선택 카드 자동표시)
- 송금: 선택 계좌 정보 자동 채움 (녹색 배경)
- 현금/가지급정산, 복지카드: 기존 수동입력 유지
- getFormData()에서 저장 시 카드/계좌 정보 items에 반영
2026-03-04 21:29:11 +09:00
김보곤
aa1153f652 fix: [approval] 법인카드 tenant_id 세션 조회, 하이패스카드 제외 2026-03-04 21:10:56 +09:00
김보곤
f506f68df5 fix: [approval] 계좌 조회 tenant 중복조건 제거, 관리링크 모달 변경 2026-03-04 21:00:28 +09:00
김보곤
f4c08de0e4 fix: [approval] 카드/계좌 1개일 때 자동 선택 2026-03-04 20:54:03 +09:00
김보곤
bfb7302f9c fix: [approval] 카드/계좌 관리 페이지 링크 수정 2026-03-04 20:42:26 +09:00
김보곤
8a52cd198f feat: [approval] 지출결의서 법인카드/송금 계좌 선택 기능
- 법인카드 선택 시 카드 목록 패널 슬라이드-다운 표시
- 송금 선택 시 출금 계좌 목록 표시, 대표계좌 자동 선택
- 선택된 카드/계좌 정보를 content JSON에 스냅샷 저장
- 상세 페이지에서 선택된 카드/계좌 정보 읽기전용 표시
2026-03-04 20:29:25 +09:00
김보곤
622fb92a92 feat: [approval] 지출결의서 첨부파일 업로드/다운로드 기능 추가
- 첨부파일 업로드 API (GCS 연동, 20MB 제한)
- 첨부파일 삭제/다운로드 API 추가
- 지출결의서 폼에 드래그&드롭 멀티 파일 업로드 UI 추가
- ApprovalService에 linkAttachments 메서드 추가 (is_temp 플래그 관리)
- show 페이지에 첨부파일 목록 표시 및 다운로드 링크
- 지출부서 기본값 '본사', 로그인 사용자 이름 자동입력, 제목 필드 제거
2026-03-04 20:07:49 +09:00
김보곤
b791b7d764 fix: [approval] 기안 작성 폼 가로 폭 제한 제거
- max-width: 960px 제거하여 전체 너비 사용
2026-03-04 15:19:00 +09:00
김보곤
e3efc4f2ee feat: [approval] 지출결의서 전용 폼 UI 추가
- Alpine.js 기반 지출결의서 전용 폼 컴포넌트 (_expense-form.blade.php)
- 지출형식/세금계산서 라디오, 내역 테이블(동적 행 추가/삭제), 금액 자동합계
- 양식 code === 'expense' 시 Quill 대신 전용 폼 표시 (create/edit)
- content JSON 구조화 저장, show 페이지 읽기전용 테이블 렌더링
- 기존 Quill 방식 하위 호환 유지
2026-03-04 15:14:18 +09:00
김보곤
1e5ebcb6b1 feat: [approval] 양식 선택 시 제목 자동 설정 기능 추가
- applyBodyTemplate에서 제목 필드가 비어있으면 양식명 자동 입력
- create/edit 공통 적용
2026-03-04 14:51:18 +09:00
김보곤
18c44f3a1c fix: [approval] 결재선 요약 카드 XSS 방어 추가
- updateApprovalLineSummary의 innerHTML에 escapeHtml 함수 적용
- user_name, position, stepLabel 출력 시 HTML 이스케이프 처리
2026-03-04 14:21:07 +09:00
김보곤
c314715008 feat: [approval] 결재선 드롭다운 직접 배치 및 양식 본문 자동 채움
- 새기안/수정 화면에 결재선 드롭다운 추가 (모달 없이 빠른 선택)
- 양식 선택 시 body_template HTML 자동 채움 (편집기 자동 활성화)
- 모달 닫을 때 외부 드롭다운 동기화
- ApprovalForm 모델 fillable에 body_template 추가
2026-03-04 14:18:54 +09:00
김보곤
c5720e8c16 chore: [eaccount] 디버그 로깅 제거
- 무한루프 버그 수정 완료 후 디버그용 로그 정리
2026-03-04 13:27:07 +09:00
김보곤
50bfaf160f fix: [eaccount] 부분 월 조회 시 무한루프 크래시 수정
- splitDateRangeMonthly()에서 endDate가 월 중간인 경우
  cursor가 같은 달 1일로 되돌아가 무한루프 발생
- cursor 이동 로직 수정: chunkEnd+1일→월초 대신 chunkStart+1월→월초
- 부분 월, 전체 월, 다중 월 모든 케이스 테스트 완료
2026-03-04 13:26:11 +09:00
김보곤
85410ab760 fix: [eaccount] 500 에러 디버깅용 상세 로깅 추가
- getAllAccountsTransactions 단계별 로그 (계좌수, 계좌별 시작/완료)
- transactions 분기점 로그 (bankAccountNum, 기간)
2026-03-04 13:23:05 +09:00
김보곤
c52b73696e fix: [roadmap] 개발서버 문서 경로 설정 가능하도록 개선
- config/roadmap.php 추가 (ROADMAP_DOCS_BASE 환경변수)
- RoadmapController에서 config 기반 경로 사용
- 로컬: base_path('../docs') 기본값 유지
- 서버: .env에서 ROADMAP_DOCS_BASE 설정
2026-03-04 13:21:40 +09:00
김보곤
94674a2dac fix: [barobill] 전체 바로빌 컨트롤러 WSDL 캐싱 활성화
- EcardController, HometaxController, EtaxController
- WSDL_CACHE_NONE → WSDL_CACHE_BOTH (불필요한 WSDL 재다운로드 방지)
2026-03-04 13:15:59 +09:00
김보곤
42650000c4 fix: [eaccount] SOAP 호출 크래시 방지 — WSDL 캐싱 + 소켓 타임아웃 + 진단 로깅
- WSDL_CACHE_NONE → WSDL_CACHE_BOTH (매 요청 WSDL 재다운로드 방지)
- default_socket_timeout 60→120초 연장
- register_shutdown_function으로 Fatal Error 감지/로깅
- callSoap에 SOAP 호출 소요시간 로깅 추가
2026-03-04 13:14:40 +09:00
김보곤
46bb3f190b fix: [eaccount] 운영서버 500 에러 디버깅 — set_time_limit 안전 처리 + 상세 에러 메시지 2026-03-04 13:03:35 +09:00
김보곤
fe892d81ec fix: [ecard] 기간 검색 stale closure 문제 수정
- loadTransactions/loadSplits/loadJournalStatuses에 명시적 날짜 파라미터 추가
- 조회 버튼 클릭 시 현재 날짜 직접 전달
- 편의 버튼(이번달/지난달/D-N월) 클릭 시 자동 검색 트리거
2026-03-04 12:57:42 +09:00
김보곤
35696400a2 fix: [eaccount] 기간 검색 시 stale closure 문제 수정
- loadTransactions/loadSplits에 명시적 날짜 파라미터 추가
- 조회 버튼 클릭 시 TransactionTable prop의 최신 날짜 직접 전달
- 편의 버튼(이번달/지난달/D-N월) 클릭 시 자동 검색 트리거
2026-03-04 12:50:49 +09:00
김보곤
c0f606a949 fix: [journal] 계좌 출처 전표 일반전표에서 수정 허용
- 카드/세금계산서 출처 → 잠금 유지 (원본에서 수정)
- 계좌(bank_transaction) 출처 → 일반전표에서 수정 허용
- 프론트엔드 UI + 백엔드 update 메서드 동시 수정
2026-03-04 12:42:35 +09:00
김보곤
caf549b2a0 fix: [eaccount] 12월분 조회 타임아웃 오류 수정
- PHP set_time_limit(120) 추가 (SOAP 다건 호출 시 기본 30초 초과 방지)
- 프론트엔드 응답 상태/빈 응답 체크 추가 (에러 원인 구체화)
2026-03-04 12:39:36 +09:00
김보곤
2813f31f7b feat: [china-tech] 유니트리 왕싱싱 5번째 탭 추가
- 공급망 혁신 인터랙티브 비교 (기존 vs 유니트리 수직 계열화)
- H1/G1 휴머노이드 제어 알고리즘 탭 전환 UI
- 시장 점유율 도넛 차트, 가격 파괴 현황 Bar 차트
- ut- 접두사로 외부 함수 충돌 방지
2026-03-04 11:16:59 +09:00
김보곤
a6cc2fd2b4 fix: [payables] JournalEntry 모델 use 문 누락 수정 2026-03-04 11:16:58 +09:00
김보곤
1dee6d0de8 fix: [payables] 전표 삭제 500 에러 수정
- Accept: application/json 헤더 추가 (HTML 응답 방지)
- findOrFail → find + 수동 404 처리 (에러 메시지 개선)
- try-catch 추가로 상세 에러 메시지 반환
2026-03-04 11:13:14 +09:00
김보곤
2a2b3bb6ee fix: [payables] 전표 삭제 라우트 순서 수정
- journal-entry/{id}를 /{id}보다 위로 이동하여 라우트 충돌 해결
2026-03-04 11:07:39 +09:00
김보곤
1c8d06eb99 feat: [payables] 미지급금관리 전표 삭제 기능 추가
- 일반전표 상세 행에 삭제 버튼(휴지통 아이콘) 추가
- DELETE /finance/payables/journal-entry/{id} API 추가
- journal_entry_id 필드를 프론트에 전달하도록 쿼리 수정
- 삭제 후 데이터 자동 새로고침
2026-03-04 11:02:06 +09:00
김보곤
fa0740bb17 feat: [china-tech] DeepSeek 량원펑 4번째 탭 추가
- 개요&기원, V3 핵심기술 MoE, 비용효율성, 오픈소스 생태계 4개 내부 탭
- MoE 라우팅 인터랙티브 시뮬레이션 데모
- 훈련 비용 비교 Bar 차트 (DeepSeek vs Llama vs GPT-4)
- 내부 탭 네비게이션 ds- 접두사로 외부 충돌 방지
2026-03-04 11:00:57 +09:00
김보곤
23c6eede44 feat: [journal] 일반전표입력 테이블에 전표번호 컬럼 추가
- 날짜 다음에 전표번호(entry_no) 컬럼 추가
- 운영서버에서 전표 식별/비교 용이하도록 개선
2026-03-04 10:47:50 +09:00
김보곤
91cbc9559f feat: [china-tech] 양즈린 Kimi LLM 기술 리서치 탭 추가
- 세 번째 탭: Moonshot AI 양즈린 분석 콘텐츠
- 타임라인 카드 인터랙션, 문맥 길이 바 차트, 투자 도넛 차트
2026-03-04 10:40:02 +09:00
김보곤
f8f9df98ec fix: [big-tech] Agibot 비전 섹션 가독성 개선
- 다크 배경(slate-900) → 밝은 배경(indigo-50)으로 변경
- 텍스트 색상 대비 강화 (slate-200 → slate-700)
- 비전 탭 버튼/콘텐츠 박스 밝은 테마 적용
2026-03-04 10:35:46 +09:00
김보곤
18dcec312f fix: [big-tech] Agibot 비교 테이블 레이아웃 깨짐 수정
- grid-cols-3 균등 분배 → table 요소로 교체 (컬럼 비율 제어)
- 테이블+차트를 flex 레이아웃으로 나란히 배치
- min-width 설정으로 좁은 화면에서 텍스트 줄바꿈 방지
2026-03-04 10:32:31 +09:00
김보곤
5bd74c1094 feat: [ecard] 변경사항 저장 시 기존 분개 금액 자동 갱신
- 카드 금액 수정 후 저장 시 기존 분개의 차변/대변 금액도 연동 갱신
- 공제/불공제 유형별 라인 구조 보존하면서 금액만 업데이트
- 기존 계정과목, 적요, 거래처 정보 유지
2026-03-04 10:23:14 +09:00
김보곤
ec6b72937c feat: [china-tech] Agibot 휴머노이드 로봇 정보 탭 추가
- 두 번째 탭: Agibot 원정 A1 분석 콘텐츠
- 탭 전환 시 차트 지연 초기화 (lazy init)
- 비전 단계별 인터랙티브 UI
2026-03-04 10:12:37 +09:00
김보곤
56cbd7ca21 fix: [ecard] 분개 모달에서 수정된 카드 금액 자동 반영
- 기존 분개 로드 시 카드 금액과 불일치하면 자동으로 새 금액 기준 라인 갱신
- 불일치 경고를 자동 갱신 안내 메시지로 변경
2026-03-04 10:12:35 +09:00
김보곤
af1bbe05dd fix: [ecard] 분리/분개 모달에서 수정된 금액 반영
- SplitModal의 originalAmount를 effectiveSupplyAmount + effectiveTax로 변경
- 분리 저장 시 백엔드 검증도 수정된 금액 기준으로 전달
2026-03-04 09:42:59 +09:00
김보곤
b4f0329113 feat: [china-tech] 중국의 기술도약 > 5대 신흥빅테크 페이지 추가
- BigTechController 생성 (HX-Redirect 패턴 적용)
- 5개 탭 UI 구현 (첫 번째 탭: 천텐스, 캄브리콘 AI 반도체 분석)
- Chart.js 차트 3개 (주가, 매출 비중, 성능 레이더)
2026-03-04 09:30:47 +09:00
김보곤
6f03a8d12c feat: [hr] 슈퍼관리자 근태/신청 삭제 및 영구삭제 기능 추가
- AttendanceService: forceDeleteAttendance 메서드 추가
- LeaveService: deleteLeave(모든 상태), forceDeleteLeave 메서드 추가
- Controller: force 파라미터 + 슈퍼관리자 권한 분기
- 근태 테이블: 슈퍼관리자에게 삭제/영구삭제 버튼 표시
- 신청 테이블: 슈퍼관리자에게 삭제/영구삭제 버튼 표시
2026-03-04 00:15:41 +09:00
김보곤
266040a008 fix: [hr] 통합 근태관리 탭2/3 컨테이너 HTML 추가 및 정렬 지원 2026-03-04 00:06:01 +09:00
김보곤
af325d1cab fix: [hr] 통합 근태관리 JS API URL에 /api 접두사 추가 2026-03-04 00:02:40 +09:00
김보곤
6cdcc293cf feat: [hr] 근태등록 + 휴가관리 통합 시스템 구현
- Leave 모델 확장: 6개 유형 추가 (출장/재택/외근/조퇴/지각사유서/결근사유서)
- LeaveService: 유형별 결재양식 자동 선택, 유형별 Attendance 반영 분기
- ApprovalService: 콜백 3개 결재양식코드로 확장
- AttendanceIntegratedController: 통합 화면 컨트롤러
- 통합 UI: 근태현황/신청결재/연차잔여 3탭 + 신규 신청 드롭다운
- AttendanceRequest 모델/서비스/컨트롤러/뷰 삭제 (Leave로 일원화)
- AttendanceService: deductLeaveBalance 제거 (Leave 시스템으로 일원화)
2026-03-03 23:50:27 +09:00
김보곤
896446f388 fix: [attendance] 근태관리 승인 탭 제거
- 결재관리에서 처리하므로 승인 탭 불필요
- 탭 네비게이션, 승인 탭 콘텐츠, 승인 신청 모달 제거
- 승인/반려 JS 함수 및 탭 전환 로직 제거
2026-03-03 23:04:36 +09:00
김보곤
ff7947d5bd feat: [leave] 결재선 없을 때 빠른 생성 기능 추가
- 결재선 0개 시 경고 메시지 + '결재선 바로 생성' 버튼 표시
- 결재선 있을 때 '새 결재선 추가' 링크 표시
- 빠른 결재선 생성 모달 (z-[60]): 인원 목록 / 결재선 편집 2단 레이아웃
- 부서별 펼침/접기, 이름 검색, SortableJS 드래그 순서 변경
- 저장 후 드롭다운 동적 갱신 + 새 결재선 자동 선택
2026-03-03 22:50:34 +09:00
김보곤
d55e34357d feat: [leave] 휴가 신청 시 결재선 선택 기능 추가
- 휴가 신청 모달에 결재선 드롭다운 + 미리보기 UI 추가
- 선택된 결재선으로 결재 생성 (미선택 시 기본결재선 fallback)
- 휴가 목록에 결재진행 컬럼 추가 (원형 아이콘: ✓승인/✗반려/숫자대기/파랑현재)
- approval.steps.approver eager load 추가
2026-03-03 22:36:05 +09:00
김보곤
4513e51e50 feat: [hr] 사원관리 퇴직자 영구삭제 기능 추가
- 슈퍼관리자만 퇴직 상태 사원을 영구삭제 가능
- 관련 첨부파일도 함께 삭제
- DELETE /admin/hr/employees/{id}/force 엔드포인트 추가
2026-03-03 21:46:37 +09:00
김보곤
2277d94cac fix: [eaccount] 계좌 입출금내역 적요 중복 표시 수정
- BankTransaction::cleanSummary() 메서드 추가: 상대계좌예금주명(cast) 중복 제거
- parseTransactionLogs: 적요 표시 시 remark2 중복 제거 적용
- cacheApiTransactions: DB 저장 시에도 중복 제거 적용
- 기존 DB 데이터 45건 정리 완료
2026-03-03 21:11:35 +09:00
김보곤
7885c1581d fix: [user] 사용자 영구삭제 시 FK 제약 위반 500 에러 수정
- users.id를 참조하는 모든 FK를 information_schema에서 동적 조회
- NULLABLE FK → NULL 설정, NOT NULL FK → 관련 행 삭제
- 기존 5개 테이블만 처리하던 것을 전체 FK 대응으로 확장
2026-03-03 19:57:50 +09:00
김보곤
650f0ee3a7 fix: [hr] 사업소득자 임금대장 행 삭제 후 일괄저장 실패 수정
- 모든 행 삭제 시 "저장할 데이터가 없습니다" 오류 → 확인 후 서버 전송으로 변경
- 백엔드 validation: required|array → present|array (빈 배열 허용)
- 서버의 orphan draft 자동 삭제 로직이 정상 동작하도록 수정
2026-03-03 19:30:24 +09:00
김보곤
a276e8b2de fix: [sidebar] 메뉴 검색 시 대분류 그룹도 검색 대상에 포함
- 그룹 헤더(대분류/서브그룹)도 검색어 매칭 대상으로 추가
- 그룹 헤더 매칭 시 하위 전체 메뉴 표시 + 하이라이트 적용
2026-03-03 16:08:02 +09:00
김보곤
a605e62360 feat: [ai-quotation] 제조 견적서 자동 생성 기능 추가
- AI 2단계 분석: 고객 인터뷰 → 요구사항 추출 → 견적 산출
- 모델 확장: AiQuotation(모드/견적번호), AiQuotationItem(규격/단가/금액)
- AiQuotePriceTable 모델 신규 생성
- Create 페이지: 모듈/제조 모드 탭, 제품 카테고리, 고객 정보 입력
- Show 페이지: 제조 모드 분기 렌더링 (품목/금액/고객정보)
- Edit 페이지: 품목 인라인 편집, 할인/부가세/조건 입력
- Document: 한국 표준 제조업 견적서 양식 템플릿
- Controller/Route: update 엔드포인트, edit 라우트 추가
2026-03-03 15:57:31 +09:00
김보곤
1b50e3bb2f fix: [journal] 카드거래 수정 금액이 일반전표에 미반영되는 문제 수정
- 통합 목록 출금액: approvalAmount(원본) → supplyAmount+taxAmount(수정값) 사용
- 카드 분개 모달: 동일하게 수정된 금액 사용
- 외국결제 수수료 포함 금액 등 사용자 수정값이 정상 반영됨
2026-03-03 15:13:05 +09:00
김보곤
98e086a6e2 feat: [journal] 카드/은행 출처 전표 읽기 전용 적용
- update() 메서드에 source_type 가드 추가 (403 반환)
- 통합 목록에서 카드/은행 분개완료 행에 잠금 아이콘 표시
- handleEditEntry에 출처 전표 방어 가드 추가
- show() 응답에 source_type 필드 추가
2026-03-03 14:54:20 +09:00
김보곤
bd42adad55 feat: [hr] 사업소득자 임금대장 동적 행 입력 리디자인
- earner 고정 행 → 동적 행 추가/삭제 구조로 변경
- 상호/성명 datalist 콤보박스 (드롭다운 선택 + 직접 입력)
- display_name/business_reg_number 컬럼 직접 저장
- bulkSave: payment_id 기반 upsert + 미제출 draft 자동 삭제
- confirmed/paid 행 수정/삭제 불가 유지
- 엑셀 내보내기 display_name 직접 사용으로 단순화
2026-03-03 14:20:44 +09:00
김보곤
9b989c5190 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-manage into develop 2026-03-03 11:35:17 +09:00
김보곤
ec6e33699e fix: [payroll] 급여목록 정렬 기준을 입사일 오름차순으로 변경 2026-03-03 11:35:08 +09:00
17ba5c8dd0 fix: [deploy] Google Storage credentials 심링크 추가
- 배포 시 shared/storage/credentials → storage/credentials 심링크 생성
- Google Cloud 서비스 계정 JSON 파일 접근 보장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:21:06 +09:00
b1914434d8 fix: [deploy] 배포 시 .env 권한 640 보장 추가
- Production 배포 스크립트에 chmod 640 추가
- vi 편집으로 인한 .env 권한 변경(600) 방지
- 2026-03-03 장애 재발 방지 (PHP-FPM이 .env 읽기 실패 → 500)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:51:08 +09:00
김보곤
193cd2666f chore: [ai] Gemini 모델 gemini-2.0-flash → gemini-2.5-flash 마이그레이션
- config/services.php fallback 기본값 변경
- AiConfig DEFAULT_MODELS 상수 + getActiveGemini() fallback 변경
- NotionService fallback 변경
- AI 설정 관리 UI placeholder/기본값 변경
- Google Cloud AI 가이드 서비스 현황 모델명 변경
- 환경변수 관리 아카데미 예시 변경
2026-03-03 08:09:06 +09:00
김보곤
3c37050b30 feat: [approval] 결재관리 삭제 권한 기능 추가
- 관리자/슈퍼관리자 모든 상태 결재 문서 삭제 가능
- 일반 사용자는 기존대로 draft + 본인 기안만 삭제
- 진행 중 문서 삭제 시 휴가 연동 취소 처리
- 삭제 API 403 권한 검증 추가
- 상세 페이지 삭제 버튼 + 2중 확인 다이얼로그
2026-03-03 07:35:59 +09:00
김보곤
e5ab358a76 feat: [ai-quotation] 견적서 5종 템플릿 선택 시스템 추가
- classic(클래식), modern(모던), blue(블루), dark(다크), colorful(컬러풀) 5종
- 문서 상단 미리보기 카드 클릭으로 즉시 디자인 전환
- URL 쿼리 파라미터 ?template=xxx 방식, 기본값 classic
- 인쇄/PDF 시 선택 UI 자동 숨김 (no-print)
- 기존 디자인은 classic 템플릿으로 100% 보존
2026-03-02 19:27:36 +09:00
김보곤
f55e576277 fix: [ai-quotation] 견적서 대표이사명 수정 (이의찬) 2026-03-02 19:13:59 +09:00
김보곤
eb45fc608e feat: [ai-quotation] 제조업 표준 견적서 문서 뷰 추가
- 인쇄 전용 standalone 레이아웃 (layouts/document.blade.php) 생성
- 한국 제조업 표준 견적서 양식 문서 뷰 생성 (A4 인쇄/PDF 최적화)
- RdController에 documentQuotation 메서드 추가
- /rd/ai-quotation/{id}/document 라우트 등록
- 상세 페이지에 "견적서 보기" 버튼 추가 (완료 상태만 표시)
- 한글 금액 변환, VAT 자동 계산, 비고란 포함
2026-03-02 19:11:33 +09:00
김보곤
896c84475c feat: [credit] 신용평가 개발문서 페이지 추가
- 쿠콘(KooCon) API 연동 가이드 10개 섹션 구성
- 라우트, 컨트롤러, Blade 뷰 추가
2026-03-02 18:39:46 +09:00
김보곤
0aa432eb39 feat: [rd] AI 견적 생성 폼에 샘플 인터뷰 자동입력 버튼 추가
- 번개 아이콘 버튼 클릭 시 제목 + 인터뷰 내용 샘플 자동 입력
- 데모/설명용 기능
2026-03-02 18:12:16 +09:00
김보곤
2803e4a53a fix: [rd] API 호출 URL /admin → /api/admin 수정
- index, create, show 뷰의 fetch URL을 /api/admin/rd/... 로 수정
- api.php 라우트는 api/ prefix가 자동 적용됨
2026-03-02 18:03:08 +09:00
김보곤
5c98c0be93 feat: [barobill] 회원사 필수 설정 가이드 섹션 추가
- 파트너사/회원사 구조 설명 (코드브릿지엑스 → 회원사)
- 6단계 설정 가이드: 회원사등록, 공동인증서, 계좌, 카드, 서비스활성화, 충전
- 회원사가 바로빌 사이트에서 직접 수행해야 할 작업 명시
- 체크리스트 포함, 기존 섹션 번호 2~10 → 3~11로 조정
2026-03-02 17:52:29 +09:00
김보곤
a3afa1a405 feat: [rd] AI 견적 엔진 Phase 1 구현
- 모델 3개: AiQuotationModule, AiQuotation, AiQuotationItem
- AiQuotationService: Gemini/Claude 2단계 AI 파이프라인
- RdController: R&D 대시보드 + AI 견적 Blade 화면
- AiQuotationController: AI 견적 API (생성/목록/상세/재분석)
- Blade 뷰: 대시보드, 목록, 생성, 상세, HTMX 테이블
- 라우트: /rd/* (web), /admin/rd/* (api)
2026-03-02 17:43:47 +09:00
김보곤
1299543f4d feat: [barobill] 바로빌 개발문서 페이지 추가
- 라우트, 컨트롤러, Blade 뷰 생성
- 10개 섹션: 서비스 소개, 과금 구조, 시스템 구조, 트러블슈팅 등
- 기존 카카오톡 가이드 스타일 준용
2026-03-02 17:39:52 +09:00
김보곤
b927612c58 fix: [menu-sync] 대분류 메뉴 push 시 null URL validation 오류 수정
- import validation에서 url 필드를 required → nullable로 변경
- push/getChildrenData에서 null URL을 빈 문자열로 대체
- importMenu에서 빈 URL을 null로 저장
- push 에러 응답에 원격 서버 에러 메시지 포함
2026-03-02 16:35:10 +09:00
김보곤
60ec2408ca fix: [roadmap] 로드맵 문서 마크다운 렌더링 스타일 개선
- Tailwind prose 클래스 → 커스텀 .markdown-body CSS로 변경
- 테이블 보더, 코드 블록, 제목 계층, 인용문 스타일 추가
2026-03-02 16:19:20 +09:00
김보곤
64b005b697 feat: [roadmap] AI 견적서 자동생성 엔진 개발 계획 문서 추가 2026-03-02 16:14:23 +09:00
김보곤
61df5f104a feat: [roadmap] 로드맵 문서 페이지 추가
- sam/docs 중장기 계획 문서를 렌더링하는 전용 페이지
- 비전&전략, 프로젝트 런칭, 제품 설계, 시스템 개요 4개 카테고리
- Markdown → HTML 변환 (Str::markdown)
- /roadmap/documents 목록 + /roadmap/documents/{slug} 상세
2026-03-02 16:02:51 +09:00
김보곤
f3f1416004 feat: [roadmap] 중장기 계획 메뉴 및 전용 페이지 개발
- 모델: AdminRoadmapPlan, AdminRoadmapMilestone
- 서비스: RoadmapPlanService, RoadmapMilestoneService
- FormRequest: Store/Update Plan/Milestone 4개
- 컨트롤러: Blade(RoadmapController), API(Plan/Milestone) 3개
- 라우트: web.php, api.php에 roadmap 라우트 추가
- Blade 뷰: 대시보드, 목록, 생성, 수정, 상세, 파셜 테이블 6개
- HTMX 기반 필터링/페이지네이션, 마일스톤 인라인 추가/토글
2026-03-02 15:50:20 +09:00
김보곤
458e5f890a feat: [google-cloud] AI 활용 가이드 PPTX 다운로드 기능 추가
- AiGuideController에 download() 메서드 추가
- AI 활용 가이드 PPTX 다운로드 라우트 추가
- 뷰에 PPTX 다운로드 버튼 추가
- 7장 슬라이드 HTML → PPTX 변환 파일 포함
2026-03-02 15:15:06 +09:00
김보곤
411f4a596c feat: [google-cloud] AI 활용 가이드 페이지 추가
- AiGuideController 생성 (HX-Redirect 패턴)
- STT, Speaker Diarization, 회의록, 음성녹음, Gemini, GCS, AI Config 정보 페이지
- Google Cloud 메뉴 하위에 라우트 등록
2026-03-02 14:48:42 +09:00
김보곤
1faa23ebc5 fix: [menu] 메뉴 관리 페이지 기본 행 표시 개수를 500으로 변경 2026-03-02 14:30:54 +09:00
김보곤
5e592b2f3d feat: [google-cloud] Gemini 2.0 모델 2026.06.01 중단 안내 추가
- 모델 테이블에 중단 예정 뱃지 및 취소선 표시
- 마이그레이션 가이드 경고 박스 추가 (2.0→2.5 권장)
- 바 차트에서 중단 모델 흐림(opacity) 처리
2026-03-02 14:14:53 +09:00
김보곤
c3284a6dca feat: [google-cloud] Google Cloud 메뉴 섹션 추가
- Workspace 정책: 계정관리, 2단계인증, 감사로그, 데이터보존
- Workspace 요금: 4티어 비교, 기능비교, 예상비용, 인상히스토리
- Cloud API 요금: Gemini 모델 단가, 추가기능, Storage, 비용시뮬레이션
- 컨트롤러 3개 + 뷰 3개 + 라우트 그룹 추가
2026-03-02 14:07:17 +09:00
김보곤
f051dadabb feat: [claude-code] 활용방안 PPTX 다운로드 기능 추가
- UsagePlanController에 download 메서드 추가
- 라우트에 /usage-plan/download 추가
- 뷰 헤더에 PPTX 다운로드 버튼 추가
- 7장 슬라이드 PPTX 파일 배치
2026-03-02 13:24:43 +09:00
김보곤
1e96a1287c feat: [claude-code] SAM 활용방안 페이지 추가
- 컨트롤러, 뷰, 라우트 생성
- 7개 섹션: 핵심요약, Before/After, 프로세스플로우, 80%공통화론, 멀티테넌시, AI자동화, 로드맵
- HX-Redirect 패턴 적용
2026-03-02 12:32:47 +09:00
김보곤
33a0b43d6d feat: [claude-code] Cowork 소개 페이지 추가
- Claude Code vs Cowork 핵심 차이 비교 테이블
- SAM 직무별 활용 시나리오 (영업/관리/생산)
- 업무 유형별 도구 선택 가이드
- Cowork 시작 3단계 안내
2026-03-02 11:40:04 +09:00
김보곤
f5ed38abbb feat: [claude-code] 요금정책 PPTX 다운로드 기능 추가
- 7페이지 상세 PPTX 생성 (표지/API단가/비용비교/팀비용/상세비교/전환가이드/결론)
- PricingController download 메서드 추가
- 페이지 헤더에 PPTX 다운로드 버튼 추가
- .gitignore에 public/downloads/*.pptx 예외 추가
2026-03-02 11:25:22 +09:00
김보곤
8413dd1c88 feat: [claude-code] 요금정책 비교 분석 페이지 추가
- API 토큰 단가 테이블 (Sonnet/Opus/Haiku)
- 사용 강도별 Max 20x vs Max 5x+API 비용 비교 (시각적 바 차트)
- 5인 팀 기준 총 비용 비교 (현재/Max/Team Premium)
- 최종 결론: Team Premium 연간 결제 추천
2026-03-02 11:07:47 +09:00
김보곤
5863e3148b fix: [claude-code] body_html_ko 미존재 시 fallback 처리 2026-03-02 10:57:05 +09:00
김보곤
f152d1537f feat: [claude-code] 뉴스 페이지 한/영 토글 기능 추가
- Google Translate API 연동으로 릴리즈 노트 한국어 자동 번역
- 코드 블록 보호 처리 (번역 대상에서 제외)
- 긴 텍스트 단락 분할 번역 지원
- Alpine.js 한국어/English 토글 버튼 (localStorage 저장)
- 기본값: 한국어
2026-03-02 10:55:32 +09:00
김보곤
5a0bb45b51 feat: [claude-code] Claude Code 뉴스 페이지 추가
- GitHub Releases API 연동 서비스 (1시간 캐싱)
- 뉴스 컨트롤러 + Blade 뷰 (릴리즈 카드 목록)
- /claude-code/news 라우트 그룹 등록
2026-03-02 10:41:50 +09:00
김보곤
b2226341ee fix: [interview] 대분류 하위 '+ 중분류 추가' 버튼 개선
- 기존 '+' 단일문자 → '+ 중분류 추가' 텍스트 버튼으로 변경
- children 목록 하단에 항상 표시되도록 위치 이동
2026-02-28 21:33:47 +09:00
김보곤
2a45b6bfe8 feat: [interview] 카테고리 계층 구조(대분류/중분류) 지원
- InterviewCategory 모델에 parent/children 관계 추가
- Service: getTree, getProjectTree 루트+children eager loading
- Service: createCategory에 parent_id 지원
- Service: cloneMaster 2단계 계층 복제
- Controller: storeCategory validation에 parent_id 추가
- UI: CategorySidebar/DomainSidebar 트리 뷰 렌더링
- UI: findCategory 헬퍼로 트리 내 카테고리 검색
2026-02-28 21:23:30 +09:00
김보곤
9823945807 feat: [payroll] 전표 생성 도움말 모달 추가
- 전표 생성 버튼 옆에 i 도움말 아이콘 버튼 추가
- 분개 구조, 전표일자/번호, 주의사항, 수정/삭제 안내 포함
2026-02-28 20:31:15 +09:00
김보곤
baf1fb5ddf fix: [document-templates] 양식 디자이너 미리보기 렌더러 분기 처리
- builder_type=block 템플릿은 buildBlockPreviewHtml() 사용
- 레거시 템플릿은 기존 buildDocumentPreviewHtml() 유지
2026-02-28 20:16:37 +09:00
김보곤
18fb810f81 fix: [document] '블록 빌더' → '양식 디자이너' 명칭 변경 2026-02-28 20:11:45 +09:00
김보곤
74400cd6e2 feat: [payroll] 급여 일반전표 자동 생성 기능
- PayrollController에 generateJournalEntry() 메서드 추가
- 해당월 급여 합산 → 분개 행 자동 구성 (차변 801 급여, 대변 207/205)
- 중복 체크 (source_type=payroll, source_key=payroll-YYYY-MM)
- 0원 항목 행 제외, 차대 균형 검증
- 급여관리 페이지에 전표 생성 버튼 추가
2026-02-28 20:05:58 +09:00
김보곤
7ba438b41b feat: [interview] 인터뷰 시나리오 고도화 Phase 1 구현
- InterviewProject/Attachment/Knowledge 모델 3개 신규
- 기존 모델 확장 (Question, Answer, Session, Category)
- 서비스 확장: 프로젝트 CRUD, 첨부파일, 지식 관리
- 컨트롤러 확장: 프로젝트/첨부/지식 API 엔드포인트
- 라우트 20개 추가 (프로젝트, 첨부, 지식)
- InterviewQuestionMasterSeeder: 8개 도메인 80개 질문
- UI 확장: 프로젝트 모드/기존 모드 전환
  - 프로젝트 선택 바, 상태 바, 도메인 사이드바
  - 탭 구조 (질문편집/인터뷰/첨부파일/추출지식)
  - 구조화 답변 입력 (테이블, 수식, 다중선택 등)
  - 첨부파일 업로드/관리
  - 지식 수동 추가/검증/필터링
2026-02-28 20:02:47 +09:00
김보곤
86cc18020a fix: [document] 블록 빌더 Blade 이스케이프 오류 수정
- {{today}} → @{{today}} (Blade가 PHP 상수로 해석하는 문제)
2026-02-28 19:54:34 +09:00
김보곤
8b55bef385 feat: [document] 범용 블록 빌더 Phase 1 구현
- block-editor.blade.php: 3패널 UI (Palette + Canvas + Properties)
- Alpine.js blockEditor() 컴포넌트 (CRUD, Undo/Redo, SortableJS)
- 기본 Block 6종: heading, paragraph, table, columns, divider, spacer
- 폼 필드 Block 7종: text, number, date, select, checkbox, textarea, signature
- BlockRendererService: JSON → HTML 렌더링 서비스
- 컨트롤러 분기: builder_type = 'block' → 블록 빌더 뷰
- 라우트 추가: block-create, block-edit
- API store/update에 schema JSON 처리 추가
- index 페이지에 블록 빌더 진입 버튼 추가
- 목록에 builder_type 뱃지 표시
2026-02-28 19:31:57 +09:00
김보곤
2aea6962ef feat: [payroll] 급여계산 도움말 모달 추가
- 급여관리 제목 옆 i 아이콘 버튼 추가
- 모달 내용: 전체 흐름, 지급항목, 4대보험, 세금, 실수령액, 예시, 상태 흐름, 팁
- 현재 설정된 요율/상하한 값을 동적으로 반영
2026-02-28 18:30:39 +09:00
김보곤
3443fd7b05 feat: [payroll] 급여명세서 엑셀 내보내기 CSV → XLSX 변환
- 제목행 병합 + 14pt 굵게 가운데 정렬
- 남색(#1F3864) 헤더 + 흰색 글씨 + wrapText
- 금액 열(D~O) #,##0 천단위 서식 + 오른쪽 정렬
- 합계행 SUM 수식 + 회색 배경 + 굵게
- 빈 행 포함 최소 10행까지 전체 테두리
- 파일명: 급여명세서_{year}년{month}월_{Ymd}.xlsx
2026-02-28 18:24:33 +09:00
김보곤
cb0f72e36c fix: [barobill] SVG viewBox/path 속성 이중 인코딩 오류 수정
- &quot; 로 이스케이프된 SVG HTML을 component prop에 직접 전달하면
  sanitizeComponentAttribute()가 이중 인코딩하여 SVG 파서 에러 발생
- @php 블록에서 변수로 정의 후 prop 전달 방식으로 변경
- 영향 파일: settings, etax, hometax 바로빌 페이지 3개
2026-02-28 17:51:54 +09:00
김보곤
d697f80340 fix: [hr] 사업소득자 임금대장 버튼 라벨 CSV→엑셀 내보내기 변경 2026-02-28 17:47:56 +09:00
김보곤
c36539f2bd fix: [hr] XLSX 내보내기 Color 객체 → argb 배열로 수정
- applyFromArray()에 Color 객체 직접 전달 시 TypeError 발생
- font/fill/border color를 ['argb' => 'FF...'] 배열 형태로 변경
2026-02-28 17:43:14 +09:00
김보곤
f372791ba9 feat: [hr] 사업소득자 임금대장 CSV→XLSX 내보내기 변환
- PhpSpreadsheet로 스타일링된 XLSX 생성 (제목, 남색 헤더, 테두리)
- 금액 열 천 단위 구분(#,##0), 지급일자 빨간색
- earner 프로필 일괄 로드로 사업자등록번호/주민번호 표시
2026-02-28 17:13:01 +09:00
김보곤
90d7639884 docs: [equipment] 설비관리 도움말 업데이트 - 관리자 정/부, QR 모바일 점검, 다주기 점검 내용 추가
- 담당자 → 관리자 정/부 용어 전체 변경
- QR 코드 생성/다운로드/인쇄 안내 추가
- QR 모바일 점검 섹션 신규 추가 (흐름, 특징, 팁)
- 6단계 점검 주기(일일~반년) 안내 추가
- 휴일/주말 일일점검 제한 설명 추가
- 점검 데이터 초기화 안내 추가
- FAQ 2건 추가 (QR 모바일 점검, 데이터 초기화)
2026-02-28 16:23:03 +09:00
김보곤
744acca395 feat: [equipment] 설비 목록에 관리자 정/부 열 분리 및 QR 코드 열 추가
- 담당자 단일 열 → 관리자 정, 관리자 부 2열로 분리
- QR 코드 아이콘 열 추가 (클릭 시 모달로 QR 표시)
- QR PNG 다운로드 기능 포함
2026-02-28 16:17:27 +09:00
김보곤
9f0e038ffe fix: [mobile] 모바일 점검 페이지 담당자 → 관리자 정/부 라벨 변경
- 담당: → 관리자 정: 으로 변경
- 관리자 부 표시 추가
2026-02-28 16:15:04 +09:00
김보곤
272a4842e8 fix: [leaves] 최종결재자 조회 시 reorder() 적용
- steps() 관계의 기본 orderBy(ASC)와 충돌 방지
- reorder('step_order', 'desc')로 마지막 승인자 정확히 조회
2026-02-28 16:03:32 +09:00
김보곤
05845b5311 fix: [equipment] 담당자 → 관리자 정/부 라벨 변경 및 sub_manager_id 저장 버그 수정
- 설비 등록/수정 폼 라벨: 정 담당자 → 관리자 정, 부 담당자 → 관리자 부
- 상세보기(basic-info) 라벨 동일 변경
- StoreEquipmentRequest, UpdateEquipmentRequest에 sub_manager_id 검증 규칙 추가
- 기존에 sub_manager_id가 validated()에서 누락되어 저장되지 않던 버그 수정
2026-02-28 16:03:00 +09:00
김보곤
4d375d2725 fix: [leaves] 결재 승인 시 최종결재자 ID 조회 수정
- $approval->steps (캐시된 컬렉션) → $approval->steps() (fresh 쿼리)로 변경
- 트랜잭션 내에서 업데이트된 step이 정확히 반영되도록 수정
2026-02-28 16:01:36 +09:00
김보곤
50c0c9ce50 feat: [leaves] 휴가신청 → 전자결재 자동 연동
- LeaveService: 휴가 신청 시 결재 자동 생성+상신
- LeaveService: approveByApproval/rejectByApproval 메서드 추가
- LeaveService: deletePendingLeave 시 연결된 결재 자동 취소
- ApprovalService: 승인/반려/회수/전결 시 휴가 상태 자동 동기화
- Leave 모델: approval_id, approval() 관계 추가
- UI: pending 휴가에 결재 상세 링크 추가, 승인/반려 버튼 제거
2026-02-28 15:54:41 +09:00
김보곤
9a69af98f0 fix: [equipment] 휴일/주말 차단을 일일 점검에만 적용
- 주간~반년 점검은 열이 기간을 대표하므로 비근무일 차단 제거
- 그리드 헤더, 셀 클릭, 판정 계산 모두 $isDaily 조건 추가
- toggleDetail/setResult 서버 차단도 daily에서만 적용
2026-02-28 15:50:36 +09:00
김보곤
3d8606f4d5 feat: [equipment] 점검 데이터 초기화 기능 추가
- 개별 설비 초기화: 장비명 하단 초기화 아이콘 클릭 → 확인 → 해당 월 점검 삭제
- 전체 초기화: 조회 버튼 옆 '전체 초기화' 버튼 → 확인 → 전체 설비 점검 삭제
- DELETE /inspections/reset (개별), /inspections/reset-all (전체) API
- canInspect 권한 체크 적용 (개별 초기화)
- SweetAlert 확인 모달로 실수 방지
2026-02-28 15:46:01 +09:00
김보곤
bdc1b2d3e0 feat: [approval] 결재 알림 드롭다운을 모달로 전환 + 로그인 시 자동 팝업
- 380px 드롭다운 → 560px 전체 화면 모달로 확장
- 로그인 시 미처리 결재 있으면 자동 팝업 (세션당 1회)
- ESC키/backdrop 클릭으로 모달 닫기 지원
- 모달 내 결재 카드: 긴급뱃지, 기안자, 양식, 날짜, 결재하기 링크
- 60초 뱃지 갱신 유지, per_page 10→20으로 확대
2026-02-28 15:33:05 +09:00
김보곤
775b654a26 fix: [equipment] 점검표 휴일 표시 및 주간 1주 저장 버그 수정
- 점검 그리드에 holidays 테이블 기반 휴일 표시 (빨간 배경)
- 휴일/주말 셀 클릭 차단 (UI + 서버 양쪽)
- 자동 판정에서 휴일 제외 (기존 주말만 제외 → 주말+휴일)
- 주간 1주 열 저장 누락 수정 (resolvePeriod에서 isoWeekYear 사용)
- toggleDetail, setResult에 비근무일 검증 추가
- 범례에 '휴일/주말 (점검 불가)' 안내 추가
2026-02-28 15:29:56 +09:00
김보곤
7568fabc18 fix: [approvals] 기안함 뱃지를 진행중 상태 건수로 변경
- 기존: draft(임시저장) 상태 건수 표시
- 변경: pending(진행중) 상태의 내 기안 건수 표시
2026-02-28 15:23:16 +09:00
김보곤
472a1e5c54 fix: [approvals] 뱃지 데이터 View::share 덮어쓰기 문제 수정
- AppServiceProvider와 ViewServiceProvider에서 각각 View::share 호출하여 덮어쓰기 발생
- ViewServiceProvider 한 곳에서 영업+결재 뱃지를 통합 관리하도록 수정
- AppServiceProvider에서 뱃지 로직 제거
2026-02-28 15:21:16 +09:00
김보곤
19eea07041 feat: [equipment] 설비 QR 코드 점검 시스템 추가
- 설비 상세 basic-info 탭에 QR 코드 표시 (qrcode.js CDN)
- QR PNG 다운로드/인쇄 기능
- 모바일 전용 레이아웃 (layouts/mobile.blade.php)
- 모바일 점검 페이지 (/m/inspect/{id})
- setResult API (PATCH /inspections/set-result)
- 4버튼 직접 결과 설정 (양호/이상/수리/취소)
- 전체 양호 일괄 처리
- 주기 탭 전환 (활성 주기만 표시)
2026-02-28 15:17:40 +09:00
김보곤
fa086147de feat: [approvals] 사이드바 결재 뱃지 색상 함별 차별화
- 결재함: 빨간색(#ef4444), 기안함: 파란색(#3b82f6), 참조함: 초록색(#10b981)
- 뱃지 데이터에 color 속성 추가, menu-item에서 inline style로 적용
2026-02-28 15:16:54 +09:00
김보곤
7a1b502f5c fix: [approvals] 사이드바 뱃지 Blade 컴포넌트 스코프 격리 문제 수정
- View::with() → View::share()로 변경하여 <x-sidebar.menu-item> 컴포넌트에서 $menuBadges 접근 가능하도록 수정
2026-02-28 15:13:19 +09:00
김보곤
a844dcb0ac feat: [approvals] 결재 알림 뱃지 시스템 구현
- 사이드바: 결재 대기/기안함/참조함 메뉴에 빨간 뱃지 표시
- 헤더: 알림 벨 클릭 시 결재 대기 목록 드롭다운 표시
- 드롭다운: 제목/기안자/양식/긴급 여부/일시 표시, 클릭 시 상세 이동
- 뱃지 건수 60초 자동 갱신 (API: /api/admin/approvals/badge-counts)
2026-02-28 15:08:42 +09:00
김보곤
367a7bbe56 feat: [approvals] 결재선 카드 드래그 앤 드롭 순서 변경
- SortableJS로 결재선 요약 카드 드래그 앤 드롭 지원
- 순서 변경 시 Alpine 데이터 동기화 및 카드 라벨 자동 갱신
- hover/grab/ghost/chosen 시각 피드백 CSS 추가
- 2명 이상 시 '드래그하여 순서를 변경할 수 있습니다' 힌트 표시
- CSS ::after로 카드 간 화살표 표시 (드래그 시 자연스럽게 이동)
2026-02-28 14:55:15 +09:00
김보곤
55865155de fix: [approvals] 결재선 요약을 제목 아래로 이동, 카드형 표시
- 결재선 요약 바를 본문 아래에서 제목 아래로 위치 변경
- 표시 형식을 '1차 결재 / 직책 / 이름' 카드형으로 변경
- 결재/합의/참조별 색상 구분 (파랑/초록/회색)
2026-02-28 14:48:16 +09:00
김보곤
f5090b48b0 fix: [equipment] 점검표 판정 로직 - 오늘까지 도래한 날짜만 검사
- 미래 날짜는 판정에서 제외
- 일일: 오늘까지의 평일만 검사
- 기타 주기: check_date가 오늘 이전인 열만 검사
2026-02-28 14:42:12 +09:00
김보곤
1a3ec05d6d feat: [approvals] 기안 작성/수정 결재선을 모달로 전환
- 2열 레이아웃(양식 50% + 결재선 50%)을 1열 풀와이드로 변경
- 결재선 편집기를 모달로 이동, 메인 화면에 요약 바만 표시
- ESC 키로 모달 닫기 지원
- edit 페이지 로드 시 기존 결재선 요약 즉시 표시
2026-02-28 14:41:37 +09:00
김보곤
779ba7246e feat: [equipment] 점검표 판정란 자동 합격/불합격 로직 추가
- 일일점검: 주말 제외, 전체 셀 good/repaired → 합격
- 기타 주기: 전체 열 good/repaired → 합격
- 공백 또는 X(bad) 존재 시 불합격 표시
2026-02-28 14:37:09 +09:00
김보곤
c20670f165 fix: [approvals] 결재 상세 메타 정보 열 구분선 추가 (가독성 개선) 2026-02-28 14:26:58 +09:00
김보곤
8ccac1535e fix: [approvals] Quill.js CDN URL 수정 (cdn.quilljs.com → cdn.jsdelivr.net) 2026-02-28 14:21:01 +09:00
김보곤
e9277c695f feat: [approvals] 기안 본문 Quill.js 편집기 토글 기능 추가
- create/edit: 본문 라벨 옆 편집기 체크박스 + Quill.js v2 WYSIWYG 에디터
- edit: 기존 HTML body 자동 감지 → 편집기 자동 활성화
- show: HTML body 안전 렌더링 (strip_tags), plain text는 기존 방식 유지
- textarea ↔ Quill 토글 시 내용 상호 이관
2026-02-28 14:18:16 +09:00
김보곤
8a6ee9f2fe feat: [equipment] 점검항목 다른 주기로 복사 기능 추가
- 서비스: copyTemplatesToCycles 메서드 추가 (중복 항목 스킵)
- 컨트롤러: copyTemplates API 엔드포인트 추가
- UI: 다른 주기에 복사 버튼 + 체크박스 모달
2026-02-28 14:17:18 +09:00
김보곤
1c8c08b078 fix: [approvals] 기본 결재선 템플릿 드롭다운 선택 표시 수정
- selectedLineId 타입을 숫자로 초기화하여 option value와 일치시킴
2026-02-28 14:09:47 +09:00
김보곤
d729e29996 feat: [approvals] 기안 작성 시 기본 결재선 템플릿 자동 선택
- is_default=true인 결재선 템플릿을 자동으로 선택하고 steps 로드
2026-02-28 13:57:24 +09:00
김보곤
beecf0851e feat: [equipment] 다중 점검주기 + 정/부 담당자 체계 구현
- InspectionCycle enum: 6종 점검주기 상수, 열 라벨, check_date 계산
- Equipment 모델: subManager 관계, canInspect() 권한 체크
- Template/Inspection 모델: inspection_cycle fillable 추가
- EquipmentInspectionService: 주기별 점검 조회/토글/권한 체크
- 점검표 UI: 주기 탭, 동적 필터(월/연도), 주기별 그리드 열
- 점검항목 템플릿: 주기별 탭 그룹핑, 모달에 주기 선택
- 설비 등록/수정/상세: 부 담당자 필드 추가
- 권한 없는 장비 셀 비활성(cursor-not-allowed, opacity-50)
2026-02-28 12:37:37 +09:00
김보곤
0aab609dcc feat: [users] 사용자 수정 화면에 소속 부서 선택 기능 추가
- UserController: profile 쿼리에 department_id 추가
- edit.blade.php: 소속 부서 select 드롭다운 UI 추가
- UpdateUserRequest: department_id 유효성 검증 규칙 추가
- UserService: tenant_user_profiles에 department_id 저장 로직 추가
2026-02-28 12:14:14 +09:00
김보곤
2b09857637 fix: [approvals] 결재선 저장 시 직책(job_title) fallback 추가
- enrichLineSteps: position_label → job_title_label fallback
- saveApprovalSteps: 동일 fallback 적용
- position_key가 NULL이고 job_title_key만 있는 사용자 대응
2026-02-28 09:25:21 +09:00
김보곤
fce8b7011e fix: [approvals] 결재선 인원 목록 직급순 정렬 적용
- 부서 내 정렬: 직급(pos_rank) → 직책(pos_title) → 이름 순
- COALESCE로 직급/직책 없는 사용자는 하단 배치
2026-02-28 09:20:23 +09:00
김보곤
317beb1b5e style: [approvals] 결재선 관리 모달 Toss 스타일 리디자인
- CSS 변수 기반 Toss 디자인 시스템 적용
- backdrop blur + slide-up 애니메이션
- 카드 기반 결재선 목록 (arrow flow 표시)
- 커스텀 step type select, pill 버튼
- 모달/인풋/버튼 전체 톤앤매너 통일
2026-02-28 09:14:53 +09:00
김보곤
4856eedb09 feat: [approvals] 결재선 템플릿 CRUD 기능 추가
- POST/PUT/DELETE /api/admin/approvals/lines 라우트 추가
- ApprovalApiController storeLine/updateLine/destroyLine 메서드
- ApprovalService createLine/updateLine/deleteLine + enrichLineSteps 헬퍼
- 기안함 화면에 결재선 관리 버튼 + 모달 UI (목록/편집 2-state)
2026-02-28 09:07:14 +09:00
김보곤
49951d70c0 feat: [menu-sync] 순서 동기화 Push + 되돌리기 기능 추가
- pushOrder: 로컬 메뉴 순서를 원격 서버에 일괄 반영
- undoOrder: 순서 동기화 취소하여 이전 상태로 복원
- reorder: 외부 API 엔드포인트 (이름 기반 매칭)
- 세션 기반 스냅샷으로 되돌리기 지원
2026-02-28 08:41:03 +09:00
김보곤
5ebca1402d feat: [users] 재직/휴직/퇴직 상태 검색 필터 추가
- index.blade.php에 employee_status 필터 select 추가
- UserService에 tenant_user_profiles 기반 필터링 로직 추가
2026-02-28 08:31:14 +09:00
김보곤
83f10552df feat: [menus] 최상위 그룹 상단/하단 이동 버튼 추가
- depth=0 메뉴에만 이동 버튼(↕) 표시
- 클릭 시 드롭다운으로 상단/하단 이동 선택
- 기존 reorder API 재사용하여 sort_order 일괄 변경
2026-02-28 08:24:36 +09:00
김보곤
8ba619d659 feat: [users] 재직상태(재직/휴직/퇴직) 표시 및 수정 기능 추가
- 사용자 목록 테이블에 재직상태 컬럼 추가 (재직/휴직/퇴직 배지)
- 사용자 수정 화면에 재직상태 select 필드 추가
- UserService.getUsers()에 employee_status 서브쿼리 추가
- UserService.updateUser()에서 tenant_user_profiles에 employee_status 저장
- UpdateUserRequest에 employee_status validation 추가
2026-02-28 08:23:16 +09:00
김보곤
0b5429838c fix: [users] 직급/직책 툴팁이 항상 표시되는 문제 수정
- Tailwind invisible/group-hover 클래스가 빌드에 누락되어 항상 표시됨
- inline style(display:none) + onmouseenter/onmouseleave로 변경
2026-02-28 08:13:26 +09:00
김보곤
a5abd950f2 feat: [users] 직급/직책 label에 info 툴팁 아이콘 추가
- 직급: "조직 내 서열" 설명 (사원, 대리, 과장 등)
- 직책: "맡은 역할/책임" 설명 (팀장, 실장 등)
- hover 시 tooltip 표시 (group/invisible 패턴)
2026-02-28 08:11:03 +09:00
김보곤
0ee6b9f77a feat: [users] 사용자 관리에 직급/직책 입력 UI 추가
- 사용자 수정/생성 화면에 직급(position_key), 직책(job_title_key) 선택 필드 추가
- HR 사원관리의 position-add-modal 재사용 ([+] 버튼으로 새 직급/직책 추가)
- UserService에서 tenant_user_profiles 테이블에 저장 (updateOrInsert)
- UpdateUserRequest, StoreUserRequest에 validation 규칙 추가
2026-02-28 08:07:21 +09:00
김보곤
ac3b72cac6 feat: [approvals] 결재선 에디터 2패널 UI/UX 개선
- 좌측 패널: 부서별 인원 목록 (접이식 그룹핑, 검색 필터)
- 우측 패널: 결재선 (SortableJS 드래그앤드롭 순서 변경)
- 부서별 전체 인원 API 추가 (GET /api/admin/tenant-users/list)
- 결재/합의/참조 유형별 요약 바 추가
- position_key → positions 테이블 조인으로 직위 라벨 표시
2026-02-28 07:45:30 +09:00
김보곤
d5b1f05256 fix: [approvals] 결재자 검색 API 응답 필드 수정
- department_name → department 필드명 변경 (프론트엔드 호환)
- tenant_user_profiles 조인으로 position(직급) 데이터 추가
- 부서명 검색 지원 추가
2026-02-28 00:56:10 +09:00
김보곤
bcfbcc3a1e fix: [approvals] Alpine.js v3 호환 결재선 데이터 접근 방식 수정
- __x.$data (v2 문법) → _x_dataStack[0] (v3 문법)으로 변경
- 에디터에 id="approval-line-editor" 추가하여 정확한 요소 선택
- create.blade.php, edit.blade.php 동시 수정
2026-02-28 00:45:08 +09:00
김보곤
28458488d4 fix: [approval] whereColumn → where 서브쿼리 비교 오류 수정
- getPendingForMe, getBadgeCounts에서 whereColumn에 Closure 전달 오류
- whereColumn은 두 컬럼 비교용, 서브쿼리 비교는 where 사용
2026-02-27 23:47:55 +09:00
김보곤
f87f1afde0 feat: [approval] Phase 2 결재관리 고급 기능 구현
- 보류/해제: 현재 결재자가 문서를 보류하고 해제
- 전결: 이후 모든 결재를 건너뛰고 최종 승인
- 회수 강화: 회수 사유 입력, 첫 결재자 미처리 시에만 허용
- 복사 재기안: 완료/반려/회수 문서를 복사하여 새 draft 생성
- 참조 열람 추적: 미열람/열람 필터, mark-read API
- ApprovalDelegation 모델 생성 (Phase 3 위임 대결 준비)
- 뱃지 카운트에 reference_unread 추가
2026-02-27 23:41:49 +09:00
김보곤
1aa0c50c6d feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
김보곤
8c574088f4 feat: [payroll] 급여 확정 취소 기능 추가
- 확정 상태에서 작성중으로 되돌리는 기능 추가
- Model: isUnconfirmable() 상태 헬퍼 추가
- Service: unconfirmPayroll() 메서드 추가
- Controller: unconfirm() 엔드포인트 추가
- Route: POST /{id}/unconfirm 라우트 추가
- View: 확정 취소 버튼 및 JS 함수 추가
2026-02-27 22:17:15 +09:00
김보곤
f922646b7b feat: [hr] 사업소득자 임금대장 입력 기능 구현
- BusinessIncomePayment 모델 (소득세3%/지방소득세0.3% 자동계산)
- BusinessIncomePaymentService (일괄저장/통계/CSV내보내기)
- 웹/API 컨트롤러 (ALLOWED_PAYROLL_USERS 접근 제한)
- 스프레드시트 UI (인라인 편집, 실시간 세금 계산)
- HTMX 연월 변경 갱신, CSV 내보내기
2026-02-27 20:22:07 +09:00
김보곤
958a9302b0 fix: [payroll] 전월 복사/일괄 생성 시 SoftDeletes 유니크 제약 충돌 수정
- exists() 대신 withTrashed()->first()로 soft-deleted 레코드 포함 체크
- soft-deleted 레코드 존재 시 forceDelete() 후 재생성
- copyFromPreviousMonth(), bulkGenerate() 양쪽 동일 수정
2026-02-27 18:01:38 +09:00
김보곤
48dc94c0b0 feat: [payroll] 급여관리 페이지 접근 제한 (이름 기반)
- 허용 사용자: 이경호, 전진선, 김보곤
- 웹 컨트롤러: 미허용 시 안내 뷰 반환
- API 컨트롤러: 모든 엔드포인트에 403 반환
- restricted.blade.php 안내 페이지 생성
2026-02-27 17:59:50 +09:00
김보곤
22e6cacced fix: [payroll] 전월 복사 후 테이블 새로고침 함수명 수정
- refreshPayrollTable → refreshTable (실제 함수명)
2026-02-27 17:37:56 +09:00
김보곤
d55d1c3405 feat: [payroll] 전월 급여 복사 등록 기능 추가
- PayrollService에 copyFromPreviousMonth() 메서드 추가
- PayrollController에 copyFromPrevious() 액션 추가
- 전월 지급/공제 금액을 그대로 복사 (요율 재계산 없음)
- 이미 존재하는 사원/연월은 스킵 처리
2026-02-27 17:30:06 +09:00
김보곤
f81436c26f fix: [esign] 서명/도장 이미지 원본 비율 유지하여 PDF 합성
- overlayImage()에서 원본 이미지 가로세로 비율 계산
- 필드 영역 내 contain 방식 배치 (비율 유지 + 중앙 정렬)
- getimagesize 실패 시 기존 방식 폴백
2026-02-27 17:22:19 +09:00
김보곤
2a1cbdff15 fix: [esign] 운영서버 PDF 미리보기 필드값 누락 수정
- TCPDF K_PATH_FONTS를 storage/fonts/tcpdf/로 설정하여 vendor 쓰기 권한 문제 해결
- 사전 생성된 Pretendard 폰트 정의 파일 포함 (런타임 생성 불필요)
- downloadDocument() 에러 로깅 상세화 (trace 포함)
2026-02-27 16:58:53 +09:00
김보곤
78615ec6ee fix: [payroll] 수정 모드에서 공제항목 자동 재계산 방지
- 수정 모드(editingPayrollId)에서는 /calculate API 호출 생략
- 기본급 등 변경 시 총지급액/실수령액만 로컬 재합산
- 재계산 버튼 클릭 시에만 최신 요율로 서버 계산 실행
2026-02-27 16:56:28 +09:00
김보곤
1b38071fd1 fix: [payroll] 급여 수정 시 요율 재계산 제거
- updatePayroll()에서 calculateAmounts() 호출 제거
- 저장된 공제 금액을 그대로 유지 (요율 변경 영향 없음)
- 수동 수정(deduction_overrides)만 반영
- 총지급액/총공제액/실수령액은 재합산
2026-02-27 16:44:04 +09:00
김보곤
3603a06c62 feat: [esign] 완료 알림톡 템플릿 2종 선택 및 버튼 URL 도메인 치환
- 발송 UI에 서명 요청 + 완료 알림톡 템플릿 각각 선택 가능
- 선택한 완료 템플릿명을 DB에 저장하여 서명 완료 시 사용
- 버튼 URL 도메인을 현재 환경의 app.url로 자동 치환 (개발/운영 환경 대응)
2026-02-27 16:29:03 +09:00
김보곤
810e170644 feat: [hr] 사원 상세화면에 고용형태 표시 추가 2026-02-27 16:19:17 +09:00
김보곤
f6b2f0d499 fix: [payroll] 급여 수정 모달 총 지급액 계산 오류 수정
- decimal:0 캐스트로 인해 금액이 문자열로 전달되어 문자열 연결 발생
- Number()로 명시적 숫자 변환 추가
2026-02-27 16:19:17 +09:00
김보곤
5553ccf493 feat: [hr] 사원 등록/수정 폼에 고용형태 선택 추가
- Employee 모델에 EMPLOYMENT_TYPES 상수 정의 (정규직/계약직/일용직/프리랜서)
- create/edit 뷰에 고용형태 select 드롭다운 추가
2026-02-27 16:00:19 +09:00
김보곤
0c7f6b19ae fix: [payroll] 공제항목 수정 후 이전값 표시 문제 수정
- deduction_overrides validation에서 min:0 제거 (마이너스 허용)
- 수정 모달에서 calculate API 대신 DB 저장값 직접 표시
2026-02-27 15:55:22 +09:00
김보곤
0faf4e4d4e fix: [payroll] 중복 급여 등록 시 자동으로 수정 모드 전환
- store 시 동일 사원/기간 레코드가 존재하면 updatePayroll로 전환
- 기존 INSERT 실패(500) 대신 정상 수정 처리
2026-02-27 15:51:24 +09:00
김보곤
cc3aed004c fix: [payroll] 급여 등록 중복 체크 Race Condition 수정
- 중복 체크를 트랜잭션 내부로 이동 + lockForUpdate()
- UniqueConstraintViolationException 방어 처리 (500→422)
2026-02-27 15:48:29 +09:00
김보곤
66ceb06b4b feat: [payroll] 추가 공제 항목 마이너스 금액 입력 허용
- formatMoneyInput: 음수 부호(-) 유지하도록 수정
- doRecalculate/submitPayroll: amount > 0 → amount !== 0 조건 변경
- Controller validation: deductions.*.amount에서 min:0 제약 제거
- 연말정산 환급 등 음수 공제 항목 지원
2026-02-27 15:40:48 +09:00
김보곤
6d19b4bd39 fix: [payroll] 수정 모달 공제항목 자동계산값으로 표시하도록 변경
- 수정 모달 열 때 DB 저장값 대신 자동계산값으로 공제항목 표시
- 계산 방식 변경(round→floor, 구상수→DB조회)으로 인한 수동표시 오작동 해결
- 비교 API 호출에 user_id 추가하여 가족수 반영
2026-02-27 14:19:32 +09:00
김보곤
dfc207668a fix: [payroll] 공제 항목 전체 원단위 절삭(10원 단위) 적용
- 건강보험, 장기요양보험, 국민연금, 고용보험: round() → floor(x/10)*10
- 고소득 구간 근로소득세 공식 계산도 10원 단위 절삭 적용
- 지방소득세는 이전 커밋에서 이미 적용됨
2026-02-27 14:10:25 +09:00
김보곤
743ab6da34 feat: [payroll] 근로소득세 간이세액표 기반 자동 계산 기능
- IncomeTaxBracket 모델 생성 (DB 조회 방식)
- PayrollService: calculateIncomeTax DB 기반으로 리팩토링
- 10,000천원 초과 구간 공식 계산 (calculateHighIncomeTax)
- 지방소득세 10원 단위 절삭 적용
- 공제대상가족수(1~11명) 반영 (본인 + 피부양자)
- calculate API에 user_id 파라미터 추가
- 사원 select에 data-dependents 속성 추가
- 모달에 공제대상가족수 표시
2026-02-27 13:58:59 +09:00
김보곤
f02e96d4fd feat: [hr] 사업소득자관리 메뉴 신설
- BusinessIncomeEarner 모델 생성 (worker_type 글로벌 스코프)
- Employee 모델에 worker_type 글로벌 스코프 추가 (기존 사원 격리)
- BusinessIncomeEarnerService 생성 (등록/수정/삭제/조회)
- Web/API 컨트롤러 생성 (CRUD + 파일 업로드)
- 라우트 추가 (web.php, api.php)
- View 5개 생성 (index, create, show, edit, partials/table)
- 사업장등록정보 6개 필드 (사업자등록번호, 상호, 대표자명, 업태, 종목, 소재지)
2026-02-27 13:46:50 +09:00
김보곤
41693d1888 feat: [leave] 잔여연차 퇴사자 포함 및 퇴사일 기준 연차 계산
- getBalanceSummary에 resigned 상태 포함
- 퇴사자 연차는 퇴사일까지만 산출
- 퇴사자 1년 미만 재계산 대상 제외
- 상태 컬럼 추가 (재직/휴직/퇴사 배지)
- 퇴사자 행 회색 배경 시각적 구분
- 근속 계산: 퇴사자는 입사일~퇴사일 기준
2026-02-27 13:16:08 +09:00
김보곤
95de6cbd87 feat: [leave] 잔여연차 테이블에 퇴사일 컬럼 추가 2026-02-27 13:09:59 +09:00
김보곤
d99fdcc2ec feat: [leave] 잔여연차 테이블 헤더 클릭 정렬 기능 추가
- 사원, 부서, 입사일, 부여, 사용, 잔여, 소진율 컬럼 정렬 지원
- 기본 정렬: 입사일 오름차순 (빠른 순)
- 활성 정렬 컬럼 파란색 강조 + 방향 화살표 표시
2026-02-27 13:06:42 +09:00
김보곤
3d295e1ca7 fix: [employee] 부양가족 삭제가 서버에서 반영되지 않는 문제 수정
- hx-put → hx-post + _method=PUT (method spoofing) 변경
  서버 Nginx/PHP-FPM에서 PUT body 파싱 이슈 방지
- dependents_submitted 히든 마커 추가
  모든 부양가족 삭제 시 dependents 키가 폼에 없어도 서버에서 인식
- Controller에서 마커 확인 후 빈 배열로 처리하여 삭제 반영
2026-02-27 12:56:56 +09:00
김보곤
c1b097b7fe fix: [leave] 1년 미만 직원 연차 부여를 월별 발생 방식으로 수정
- 입사일~오늘 완료 월수 기준으로 연차 산출 (기존: 연말까지 선부여)
- 잔여연차 조회 시 1년 미만 직원 total_days 자동 재계산
- 도움말 가이드 연차 산출 방식 설명 갱신
2026-02-27 12:42:10 +09:00
김보곤
5fde7855bb fix: [employee] 부양가족 피부양자/장애인 체크박스 저장 안되는 문제 수정
- Alpine.js :value 반응형 바인딩 대신 hidden+checkbox 표준 패턴 적용
- hidden input value=0 (기본값) + checkbox name+value=1 (체크 시 덮어쓰기)
- HTMX form 직렬화 시 Alpine.js 동기화 타이밍 문제 해결
2026-02-27 11:39:28 +09:00
김보곤
df1e83af1b fix: [employee] 부양가족 체크박스(장애인/피부양자) 값 저장·표시 오류 수정
- Alpine x-model + hidden input 패턴 개선 (동적 :value 바인딩)
- JSON 로드 시 boolean 정규화로 체크 상태 정확히 복원
- 서비스 레이어에서 filter_var BOOLEAN 캐스팅 추가
- show 페이지 표시 로직 filter_var로 강화
2026-02-27 11:25:11 +09:00
김보곤
efebd1e1f8 fix: [leave] 근속 기간 표시 형식 개선
- 소수점 float 대신 "1년 3개월", "2개월" 형태로 표시
- 1개월 미만인 경우 "1개월 미만" 표시
2026-02-27 11:20:40 +09:00
김보곤
efc133bd78 fix: [payroll] 식대 비과세 처리 + 국민연금 상한/하한 적용 개선
- 식대(bonus)를 과세표준에서 제외하여 4대보험/세금 산출 시 비과세 처리
- 라벨 '식대' → '식대(비과세)'로 변경 (등록/수정/상세/엑셀)
- 합계 영역에 과세표준(식대 제외) 표시 추가
- 국민연금은 기존대로 settings의 상한액/하한액 적용 (과세표준 기준으로 변경)
2026-02-27 11:17:17 +09:00
김보곤
c2edef2253 feat: [leave] 잔여연차 탭 전체 직원 자동 표시 + 연차일수 자동 산출
- 사원관리 재직/휴직 직원 전체가 잔여연차 탭에 자동 표시
- balance 레코드 없는 직원은 insertOrIgnore로 자동 생성
- 입사일 기반 근속년수로 연차일수 자동 산출 (근로기준법 제60조)
- 테이블에 입사일/근속 컬럼 추가 (6→8컬럼)
2026-02-27 11:09:07 +09:00
김보곤
e45df999aa fix: [payroll] 급여 수정 모달에 공제항목 데이터 전달 누락 수정
- table.blade.php의 수정 버튼에서 pension, health_insurance, long_term_care,
  employment_insurance, income_tax, resident_tax 필드를 json_encode에 추가
2026-02-27 11:07:24 +09:00
김보곤
e6eb1d7691 feat: [payroll] 공제항목 수동 수정 기능 추가 및 상여금→식대 변경
- 공제 6개 항목(국민연금/건강보험/장기요양/고용보험/소득세/지방소득세) 수동 수정 가능
- 수동 수정 시 노란색 배경으로 시각적 구분, 재계산 버튼으로 초기화
- 서버사이드 deduction_overrides 유효성 검증 및 적용 로직 추가
- 수정 모달에서 기존 공제값 복원 및 자동계산 비교로 수동 표시
- 상여금 → 식대 라벨 변경 (등록/상세/CSV)
2026-02-27 10:58:13 +09:00
김보곤
bbdad75468 feat: [leave] 잔여연차 탭 도움말 기능 추가
- 휴가관리가이드.md 마크다운 콘텐츠 작성 (연차 산출 방식, 촉진 제도 등)
- 잔여연차 탭 헤더에 도움말(?) 버튼 추가
- help-modal.blade.php 생성 (sales 패턴 재사용)
- LeaveController에 helpGuide() 메서드 추가
- 도움말 라우트 등록
2026-02-27 10:42:21 +09:00
김보곤
5e61d20231 feat: [payroll] 급여 설정 상한액/하한액 콤마 포맷팅 적용
- 국민연금 상한액/하한액 입력에 콤마 자동 표시
- 0일 때 포커스 시 공백, blur 시 0 복원
- 설정 저장 시 콤마 제거하여 서버 전송
2026-02-27 10:07:07 +09:00
김보곤
bd85a902ad feat: [payroll] 장기요양보험 공제항목 추가
- 건강보험에서 장기요양보험 분리하여 별도 항목으로 표시
- 급여등록/수정/일괄생성/상세보기/CSV 내보내기 모두 반영
- 공제순서: 국민연금-건강보험-장기요양보험-고용보험-근로소득세-지방소득세
2026-02-27 10:06:28 +09:00
김보곤
c8cea0b67f feat: [payroll] 급여 등록 모달 금액 입력 콤마 자동 포맷팅
- 숫자 입력 시 천단위 콤마 자동 표시
- 0인 필드에 포커스 시 공백으로 표시, blur 시 0으로 복원
- 수당/공제 동적 행에도 동일하게 적용
2026-02-27 10:00:37 +09:00
김보곤
9c14f1df25 fix: [attendance] 근태현황 HTMX 부분 로드 시 스크립트 미실행 오류 수정
- index 메서드에 HX-Redirect 추가하여 전체 페이지 로드 보장
- 근태관리→근태현황 이동 시 switchTab null 참조 에러 해결
2026-02-27 09:53:27 +09:00
김보곤
11b2c0ec17 fix: [payroll] soft-deleted 레코드로 인한 중복 등록 에러 수정
- withTrashed()로 삭제된 레코드 포함하여 중복 체크
- 삭제된 레코드 존재 시 forceDelete 후 재등록 허용
2026-02-27 09:51:00 +09:00
김보곤
8d78a1ee69 fix: [payroll] 급여 등록 500 에러 수정
- 중복 급여 등록 시 유니크 제약 위반 대신 422 응답 반환
- tenant_id null 방어 처리 (세션 값이 null인 경우 기본값 적용)
2026-02-27 09:48:16 +09:00
김보곤
af17880246 fix: [payroll] tenant_id null 오류 수정
- session('selected_tenant_id')에 기본값 1 추가
- PayrollSetting::getOrCreate, scopeForTenant 수정
- PayrollService 전체 tenant_id 조회에 기본값 적용
- Payroll 모델 scopeForTenant 동일 패턴 적용
2026-02-27 09:42:58 +09:00
김보곤
1f81e6672d fix: [payroll] 급여등록 용어 및 공제항목 순서 변경
- 초과근무수당 → 고정연장근로수당 명칭 변경
- 소득세 → 근로소득세, 주민세 → 지방소득세 명칭 변경
- 공제항목 순서: 국민연금-건강보험-고용보험-근로소득세-지방소득세
- CSV 내보내기 헤더 및 데이터 순서 동일 적용
2026-02-27 09:37:05 +09:00
김보곤
43917fe486 revert: [attendance] MNG 마이그레이션 정책 변경 되돌림
- MNG 마이그레이션 파일 삭제 (API에서 관리)
- CLAUDE.md DB 아키텍처 규칙 원래대로 복원
- 마이그레이션은 API 프로젝트에서만 관리
2026-02-27 09:30:06 +09:00
김보곤
e9454d2232 feat: [attendance] attendance_requests 마이그레이션 MNG에 추가
- API 운영 배포 중지 기간 동안 MNG에서 마이그레이션 관리
- Schema::hasTable() 가드로 중복 실행 방지
- CLAUDE.md DB 아키텍처 정책 업데이트
2026-02-27 09:24:04 +09:00
김보곤
fc1efba9fd feat: [hr] 입퇴사자 현황 페이지 구현
- EmployeeService에 근속기간 조회/통계/CSV 내보내기 메서드 추가
- API 컨트롤러에 tenure/tenureExport 엔드포인트 추가
- EmployeeTenureController 뷰 컨트롤러 생성
- 통계 카드 6개 (전체/재직/퇴직/평균근속/올해입사/올해퇴사)
- HTMX 테이블 (사원/부서/직책/상태/입사일/퇴사일/근속기간/근속일수)
- 필터: 이름검색, 부서, 상태, 입사기간 범위, 정렬
- CSV 엑셀 다운로드 기능
2026-02-27 08:24:26 +09:00
김보곤
3c3e0f8141 feat: [esign] 알림톡 템플릿명 환경별 분기 (운영: 원본, 개발: _DEV)
- resolveTemplateName() 헬퍼 메서드 추가 (두 컨트롤러)
- production 환경: 전자계약_서명요청, 전자계약_완료, 전자계약_리마인드
- 개발 환경: 전자계약_서명요청_DEV, 전자계약_완료_DEV, 전자계약_리마인드_DEV
- config('app.url')은 이미 환경별 도메인 자동 사용
2026-02-27 08:15:54 +09:00
김보곤
3f1c5ead73 fix: [esign] 완료 알림톡 버튼 URL 및 이메일 PDF 서명 누락 수정
- 완료 알림톡 버튼이 서명페이지로 연결되던 문제 → 문서 다운로드 URL로 강제 변경
- 계약 완료 상태에서 signed_file_path 없을 때 서명 PDF 재생성 로직 추가
- mergeSignatures 실패 시 상세 trace 로그 추가
2026-02-26 23:28:20 +09:00
김보곤
fbbc4ba385 fix: [esign] MNG EsignSigner 모델에 역할/상태 상수 추가
- ROLE_CREATOR, ROLE_COUNTERPART 상수 추가
- STATUS_WAITING~STATUS_REJECTED 상수 추가
- 운영서버 Undefined constant 오류 수정
2026-02-26 23:15:17 +09:00
김보곤
9676f0409e fix: [esign] 서명 요청/다음 서명자 알림에 역할 기반 분기 적용
- dispatchNotification: 상대방(counterpart)만 알림톡, 본사(creator)는 이메일
- 순차 서명 시 다음 서명자 알림도 동일 역할 기반 분기 적용
- 다음 서명자 알림에서 getKakaotalkChannelId/getTemplateData 헬퍼 활용
- 알림톡 실패 시 이메일 자동 폴백 로직 통일
2026-02-26 23:03:43 +09:00
김보곤
50c43b52b0 fix: [leave] 휴가 신청 422 에러 메시지 표시 개선 2026-02-26 22:58:36 +09:00
김보곤
c26ede01b5 fix: [leave] HTMX 사이드바 네비게이션 시 HX-Redirect 적용 2026-02-26 22:56:57 +09:00
김보곤
0b19728fef feat: [esign] 서명 완료 시 상대방에게 카카오톡 알림톡으로 PDF 계약서 전달
- sendCompletionAlimtalk: 승인된 '전자계약_완료' 템플릿 조회 후 변수 치환 발송
- 버튼 URL에 PDF 다운로드 링크(/api/document) 포함
- 상대방(counterpart)만 알림톡 발송, 본사(creator)는 이메일 유지
- 알림톡 실패 시 이메일 자동 폴백 처리
- 발송 후 3초 대기하여 전달 결과 확인 로직 추가
- getKakaotalkChannelId, getTemplateData 헬퍼 메서드 추가
2026-02-26 22:54:59 +09:00
김보곤
06fb6b42be feat: [payroll] 급여관리 기능 구현
- Payroll, PayrollSetting 모델 생성
- PayrollService 구현 (CRUD, 자동계산, 간이세액표, 일괄생성)
- Web/API 컨트롤러 생성 (HTMX/JSON 이중 응답)
- 급여 목록, 통계 카드, 급여 설정 뷰 생성
- 라우트 추가 (web.php, api.php)
- 상태 흐름: draft → confirmed → paid
2026-02-26 22:49:44 +09:00
김보곤
a70df1cc2d fix: [settlement] Alpine @click에서 clearAllCheckboxes 참조 오류 수정 2026-02-26 22:45:17 +09:00
김보곤
fe739431ca fix: [esign] OTP SMS 발송 조건을 tenant_id 대신 서명자 역할 기반으로 변경
- 기존: tenant_id != 1 조건으로 본사 테넌트 전체 SMS 차단
- 변경: signer->role === counterpart 조건으로 상대방만 SMS 수신
- 본사(creator)는 이메일 OTP 유지, 상대방(counterpart)은 SMS OTP 수신
2026-02-26 22:37:13 +09:00
김보곤
684bba105f feat: [leave] 휴가관리 Phase 1 구현
- Leave, LeavePolicy, LeaveGrant 모델 생성
- LeaveBalance 헬퍼 메서드 추가 (useLeave, restoreLeave, canUse)
- LeaveService 핵심 로직 (신청, 승인, 반려, 취소, 잔여연차, 통계)
- API 컨트롤러 (목록, 등록, 승인/반려/취소, 잔여연차, 통계, CSV 내보내기)
- 뷰 컨트롤러 + 라우트 등록 (web, api)
- Blade 뷰 (index + 3개 탭 partials: table, balance, stats)
2026-02-26 22:34:31 +09:00
김보곤
b17979f412 feat: [attendance] 근태현황/근태관리 메뉴 분리
- 근태현황(/hr/attendances): 조회 전용 (목록/캘린더/요약)
- 근태관리(/hr/attendances/manage): CRUD + 승인 관리
- table-manage.blade.php: 관리용 테이블 (체크박스/수정/삭제)
- table.blade.php: 조회용 테이블 (GPS 포함, CRUD 제거)
- API 컨트롤러 view 파라미터로 테이블 분기
2026-02-26 22:20:48 +09:00
김보곤
7f5bb43372 fix: [attendance] 주간 근무시간 계산 UNSIGNED 오버플로우 수정
- CAST(... AS UNSIGNED)에서 음수값 시 2^64-1로 오버플로우되던 버그
- CAST(... AS SIGNED) + COALESCE + GREATEST(0, ...)로 안전하게 변경
- getOvertimeAlerts(), getEmployeeMonthlySummary() 두 곳 수정
2026-02-26 21:59:47 +09:00
김보곤
1bc77f94ff fix: [settlement] 구독료 수당(매니저/파트너) 로직 3가지 버그 수정
- 매니저 미지정 시 구독료가 소실되던 버그 → 파트너 수당으로 편입
- deposit/balance 양쪽에서 구독료 이중 계상 → deposit에서만 1회 기록
- 파트너별 결산 탭에 +구독 배지 추가, select에 manager_user_id 포함
2026-02-26 21:55:59 +09:00
김보곤
4398d5e27c fix: [attendance] User 모델에 tenantProfiles 관계 추가로 500 에러 수정
- User 모델에 tenantProfiles() HasMany 관계 추가 (tenant_user_profiles 테이블)
- eager loading에 department 관계도 포함하여 N+1 방지
2026-02-26 21:55:27 +09:00
김보곤
7b2300e1be fix: [attendance] cal_days_in_month → Carbon endOfMonth 대체로 500 에러 수정
- PHP calendar 확장 미설치 환경에서 cal_days_in_month 함수 호출 시 500 에러 발생
- getMonthlyStats, getMonthlyCalendarData, getEmployeeMonthlySummary 3곳 수정
2026-02-26 21:51:22 +09:00
김보곤
2550c16894 feat: [attendance] 근태관리 2차 고도화 8개 기능 구현
- 월간 캘린더 뷰 (사원별 필터, 날짜 클릭 등록, HTMX 월 이동)
- 일괄 등록 (다수 사원 체크박스 선택 후 일괄 등록, upsert 처리)
- 사원별 월간 요약 (상태별 카운트 + 총 근무시간 집계 테이블)
- 초과근무 알림 (주 48h 경고 / 52h 위험 배너)
- 근태 승인 워크플로우 (신청→승인→근태 레코드 자동 생성)
- 자동 결근 처리 (매일 23:50 스케줄러, 주말 제외)
- 연차 관리 연동 (휴가 등록 시 leave_balances 자동 차감)
- GPS 출퇴근 UI (테이블 GPS 아이콘 + 상세 모달)
- 탭 네비게이션 (목록/캘린더/요약/승인) HTMX 기반 전환
2026-02-26 20:56:25 +09:00
김보곤
94000d965d feat: [journal] 일반전표입력에 카드사용내역 분개 기능 추가
- JournalEntryController에 cardTransactions/storeFromCard/cardJournals/deleteCardJournal 메서드 추가
- 카드거래 분개 라우트 4개 추가 (card-transactions, store-from-card, card-journals, delete-card-journal)
- JournalEntryList에 카드거래 탭/필터/통계 통합
- CardJournalEntryModal 컴포넌트 추가 (공제/불공제에 따른 기본 분개 라인 자동 생성)
- source_type=ecard_transaction 호환 (기존 ecard 페이지 분개와 동일 키)
2026-02-26 20:52:44 +09:00
김보곤
474165ff67 feat: [attendance] 근태현황 Phase 1 구현
- 1-1: 등록/수정 버그 수정 (created_by 덮어쓰기 방지)
- 1-2: 엑셀(CSV) 다운로드 기능 추가
- 1-3: 체크박스 일괄 삭제 기능 추가
- 1-4: 월간 통계 연/월 선택 기능 추가
2026-02-26 20:45:19 +09:00
김보곤
fb92348a9d refactor: [hr] 사원 첨부파일을 GCS 듀얼 저장 방식으로 변경
- 업로드: 로컬 + GCS 동시 저장 (gcs_object_name, gcs_uri 기록)
- 다운로드: GCS Signed URL 우선, 로컬 폴백
- 삭제: GCS + 로컬 모두 삭제, soft delete 처리
- DashboardCalendarController 패턴 준용
2026-02-26 20:13:26 +09:00
김보곤
c5d5d0c3ab feat: [hr] 사원등록 기능 확장
- 기본정보에 주민등록번호 필드 추가
- 급여이체정보 섹션 추가 (이체은행, 예금주, 계좌번호)
- 부양가족 정보 섹션 추가 (동적 행 추가/삭제)
- 첨부파일 업로드/다운로드/삭제 기능 추가
- 은행 목록 config/banks.php 설정 파일 생성
- show 페이지 주민등록번호 뒷자리 마스킹 처리
2026-02-26 19:59:15 +09:00
김보곤
84985ceab6 feat: [employee] 입사일/퇴직일 컬럼 헤더에 정렬 아이콘 추가
- 입사일/퇴직일 컬럼 클릭 시 오름차순/내림차순 토글
- 현재 정렬 상태를 아이콘으로 표시 (↑ 오름차순, ↓ 내림차순, ↕ 미선택)
- 기본 정렬: 입사일 빠른순(오름차순)
2026-02-26 19:41:54 +09:00
김보곤
4fa163397a feat: [employee] 사원관리 정렬 기능 추가 (입사일/퇴직일)
- 정렬 드롭다운 추가: 입사일 빠른순/최신순, 퇴직일 최신순/빠른순, 상태순
- 기본 정렬을 입사일 빠른순으로 설정
- JSON 컬럼(json_extra.hire_date, resign_date) 기반 정렬
2026-02-26 19:37:07 +09:00
김보곤
e8d38953d0 feat: [hr] 근태현황 MNG 프론트엔드 구현
- Attendance 모델 (attendances 테이블, 상태/색상 매핑, check_in/check_out accessor)
- AttendanceService (목록/월간통계/CRUD, 부서/사원 드롭다운)
- API 컨트롤러 (HTMX+JSON 이중 응답, stats/index/store/update/destroy)
- 페이지 컨트롤러 (index 페이지 렌더링)
- 웹/API 라우트 등록 (hr/attendances, api/admin/hr/attendances)
- index.blade.php (통계카드+필터+등록/수정 모달)
- partials/table.blade.php (HTMX 부분 로드 테이블)
2026-02-26 19:34:07 +09:00
김보곤
b9a4a6b835 feat: [hr] 사원 목록 테이블에 퇴직일 컬럼 추가 2026-02-26 19:07:06 +09:00
김보곤
b6220810cf feat: [hr] 사원 등록/수정 - 비밀번호 제거 및 퇴직일 추가
- 비밀번호 필드 제거 (등록 폼, validation, 서비스)
- 퇴직일(resign_date) 필드 추가 (등록/수정/상세 화면)
- json_extra에 resign_date 저장/수정 지원
- Model에 resign_date accessor 추가
2026-02-26 18:59:15 +09:00
김보곤
a1ca8b7e46 refactor: [hr] 사번(employee_code) 필드 전체 제거
- Model: appends, accessor 제거
- Service: 검색 필터, json_extra 저장/수정 로직 제거
- Controller: validation 규칙 제거
- View: create, edit, show, table에서 사번 UI 제거
2026-02-26 18:50:12 +09:00
김보곤
442533e7c8 fix: [hr] searchUsers에서 q 파라미터 null 처리
- ConvertEmptyStringsToNull 미들웨어로 인해 빈 q= 가 null로 변환되는 문제
2026-02-26 17:41:47 +09:00
김보곤
9623256386 feat: [hr] 사원 등록 - 기존 직원 불러오기 기능 추가
- 검색 API (GET /api/admin/hr/employees/search-users)
- 테넌트 소속 + 사원 미등록 사용자 검색
- 기존 사용자 선택 시 Employee만 생성 (User 생성 건너뜀)
- Alpine.js 검색 UI (포커스시 목록, debounce 검색, 선택/해제)
2026-02-26 17:35:54 +09:00
김보곤
446b8787de fix: [hr] 사원 등록/수정 에러 메시지 화면 표시 개선
- API 컨트롤러 store/update/destroy에 try-catch 추가
- debug 모드에서 상세 에러 메시지 포함 응답
- create/edit 뷰에 showToast 기반 에러 표시 추가
- 422 validation 에러 필드별 메시지 표시
- 500 서버 에러 시 사용자 친화적 메시지 표시
2026-02-26 17:25:00 +09:00
김보곤
e8d4803590 fix: [hr] 사원 등록 시 users 테이블 NOT NULL 제약 오류 수정
- email 미입력 시 임시 이메일 생성 (NOT NULL 제약 대응)
- user_id 자동 생성 및 중복 방지 로직 추가
- role 필드 'ops' 기본값 설정
- Hash::make 사용으로 통일 (기존 패턴 준수)
2026-02-26 17:22:50 +09:00
김보곤
8d0dee2bb2 feat: [hr] 직급/직책 인라인 추가 기능 구현
- Position 생성 API 엔드포인트 추가 (POST /admin/hr/positions)
- 직급/직책 select 옆 "+" 버튼으로 모달 열기
- 모달에서 이름 입력 → API 저장 → 드롭다운에 자동 추가 및 선택
- 중복 key 방지 (기존 값이면 그대로 반환)
- create/edit 뷰 모두 적용
2026-02-26 17:07:12 +09:00
김보곤
ba24034020 fix: [hr] 사원 등록 시 user_tenants pivot 연동 추가
- User 생성 후 tenants()->attach() 호출 (멀티테넌트 필수)
- user_id, must_change_password, created_by 필드 추가
- 기존 UserService 패턴과 동일하게 맞춤
2026-02-26 16:53:32 +09:00
김보곤
f58436a4dc fix: [finance] 일일자금일보 입금 테이블 잔액 열 제거 2026-02-26 16:46:58 +09:00
김보곤
bb9193bcad feat: [hr] 인사관리 사원관리 Phase 1 구현
- Employee, Position 모델 생성 (tenant_user_profiles, positions 테이블)
- EmployeeService 생성 (CRUD, 통계, 필터/검색/페이지네이션)
- 뷰 컨트롤러(HR/EmployeeController) + API 컨트롤러 생성
- Blade 뷰: index(통계카드+HTMX테이블), create, edit, show, partials/table
- 라우트: web.php(/hr/employees/*), api.php(/admin/hr/employees/*)
2026-02-26 16:43:52 +09:00
김보곤
6b66172af7 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-manage into develop 2026-02-26 15:18:23 +09:00
김보곤
2a8d29be8d feat: [esign] PDF 서명 합성 폰트를 Pretendard로 통일
- NanumGothic → Pretendard-Regular.ttf 변경
- 전자계약 PDF 원본과 동일한 폰트로 필드 텍스트 렌더링
- storage/fonts/에 Pretendard-Regular.ttf 내장 (로컬/서버 공통)
2026-02-26 15:14:50 +09:00
9782082d01 Revert "fix:배포 시 storage/logs를 shared 심링크로 변경"
This reverts commit c6ddc78bc7.
2026-02-26 14:42:39 +09:00
c6ddc78bc7 fix:배포 시 storage/logs를 shared 심링크로 변경
- 기존: mkdir로 릴리즈 디렉토리에 logs 생성 → 배포마다 로그 유실
- 변경: ln -sfn shared/storage/logs → 로그 영속 보존
- 원인: 전자계약 PDF 합성 오류 추적 중 발견

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:40:00 +09:00
김보곤
b8b2e7e023 fix: [holidays] 대량 등록 모달에 기존 등록 데이터 표시
- 해당 연도에 등록된 휴일이 있으면 기존 데이터를 텍스트 형식으로 표시
- 등록된 데이터가 없을 때만 기본 공휴일 예시 표시
2026-02-26 14:34:00 +09:00
김보곤
84fe893a5b feat: [business-cards] 처리완료 삭제 기능 추가 및 기본 매수 500매로 변경
- 관리자 화면 처리완료 카드에 삭제 버튼 추가
- processed 상태만 삭제 가능 (서비스 검증)
- 파트너 명함신청 기본 매수 100매 → 500매 변경
2026-02-26 13:56:59 +09:00
김보곤
894364098d fix: [vat] 부가세 관리에서 매출(종이세금계산서) 항목 삭제
- 요약 테이블 행 삭제
- 필터 드롭다운 옵션 삭제
- 컨트롤러 계산 로직 및 stats 응답 제거
- React state 초기값에서 관련 필드 제거
2026-02-26 13:19:29 +09:00
김보곤
af542c0f41 fix: [esign] 로컬 저장 방식 법인도장이 서명 페이지에서 미표시되는 버그 수정
- store(): GCS뿐 아니라 local_path 도장도 signer에 자동 적용
- getContract(): signer에 도장 없어도 tenant_settings에서 확인하여 has_stamp 반환
- submitSignature(): 기존 계약 creator도 tenant_settings에서 도장 가져와 적용
2026-02-26 11:21:42 +09:00
김보곤
4bbabde383 fix: [sms] 발신번호 02-6347-0005로 수정 2026-02-26 11:04:58 +09:00
김보곤
b137e637a1 fix: [sms] API URL을 Blade route() 헬퍼로 변경 및 에러 상세 표시
- 하드코딩 URL → route() 기반으로 환경별 자동 대응
- 422 에러 시 validation 에러 메시지 상세 표시
2026-02-26 10:46:33 +09:00
김보곤
8e2b3ffbc8 feat: [sms] 번개 아이콘 클릭 시 테스트 데이터 자동 입력 2026-02-26 10:44:15 +09:00
김보곤
7b5235f2aa fix: [sms] 발신번호를 고정 텍스트로 변경 (셀렉트박스 제거) 2026-02-26 10:42:20 +09:00
김보곤
7404aa68cb fix: [sms] 발신번호 기본값 02-0005-0006 설정 2026-02-26 10:38:43 +09:00
김보곤
b5da40c051 feat: [sms] SMS 발송 테스트 메뉴 추가
- SmsController (WEB): 카카오톡 패턴 동일한 HX-Redirect 처리
- BarobillSmsController (API): 발송, 발신번호 조회/확인, 전송상태 조회
- SMS 발송 테스트 블레이드 뷰: 발신번호 목록, 바이트 카운터, 발송 결과 표시
- web.php: barobill/sms/send 라우트 추가
- api.php: barobill/sms API 라우트 4개 추가
2026-02-26 10:29:44 +09:00
김보곤
394dd258cd feat: [barobill] 독립 SMS API 연동 및 OTP 발송 전환
- BarobillService에 SMS WSDL 엔드포인트 추가
- sendSMSMessage, checkSMSFromNumber, getSMSFromNumbers 메서드 추가
- sendOtpViaSms를 알림톡 대체발송 → 독립 SMS API(SendSMSMessage)로 전환
2026-02-26 10:20:35 +09:00
김보곤
7044030ba8 fix: [esign] 본사(tenant_id=1) OTP는 항상 이메일 발송
- 본사는 알림톡 방식 선택 시에도 OTP를 이메일로 처리
- getContract 응답에서도 본사는 send_method를 email로 반환
2026-02-26 09:11:00 +09:00
김보곤
524aaab115 feat: [esign] OTP 인증코드 SMS 발송 기능 추가
- send_method가 alimtalk/both일 때 SMS로 OTP 발송
- 바로빌 sendATKakaotalkEx SMS 대체발송 기능 활용
- SMS 실패 시 이메일 폴백
- auth.blade.php UI 메시지 SMS/이메일 분기 표시
2026-02-26 09:06:36 +09:00
김보곤
76c60f6a92 fix: [esign] 알림톡 버튼 URL 도메인 치환 제거 (카카오 템플릿 검증 불일치 방지) 2026-02-26 08:37:15 +09:00
김보곤
370d001818 fix: [esign] 알림톡 버튼 URL 도메인을 APP_URL로 치환 (개발/운영 환경 대응) 2026-02-26 08:30:57 +09:00
김보곤
5a299ad20f fix: [esign] 알림톡 버튼 URL의 #{토큰} 변수를 실제 access_token으로 치환 2026-02-26 07:45:24 +09:00
김보곤
2f6e796e3f feat: [esign] 알림톡 템플릿 선택 기능 추가
- 바로빌 승인된 알림톡 템플릿 목록 조회 API 추가
- 서명 요청 발송 시 템플릿 선택 드롭다운 UI 추가
- 템플릿 미리보기 (본문 + 버튼) 표시
- send()에 template_name 파라미터 전달 지원
- 미선택 시 기존 하드코딩 폴백 유지
2026-02-25 22:42:29 +09:00
김보곤
38d7e137de feat: [equipment] 설비관리 도움말 페이지 추가
- /equipment/guide 라우트 및 컨트롤러 메서드 추가
- guide.blade.php 신규 생성 (히어로+TOC+5섹션+FAQ)
- academy-glossary에 equipment 도메인 용어 20개 추가
- 품질인정심사(ISO 9001) 대응 가이드, 보전 기초지식 포함
2026-02-25 22:02:33 +09:00
김보곤
d46f6d8b19 fix: [equipment] 설비 사진 원본 비율 유지 + 클릭 시 모달 확대 2026-02-25 21:46:04 +09:00
김보곤
d9261e7969 feat: [equipment] 수리이력 등록에 목업 데이터 자동 입력 버튼 추가
- 번개 아이콘 클릭 시 전체 필드에 랜덤 목업 데이터 채움
- 설비/수리자는 기존 옵션에서 랜덤 선택
- 외주업체는 보전구분이 외주일 때만 입력
2026-02-25 21:30:16 +09:00
김보곤
0b3ab8d07b feat: [equipment] 목록 필터 상태를 sessionStorage에 저장/복원
- 검색/필터 후 수정 페이지 이동 → 목록 복귀 시 필터 유지
- 페이지네이션, select 변경 시에도 자동 저장
2026-02-25 21:20:14 +09:00
김보곤
b62521213a fix: [equipment] 사진 URL을 Signed URL로 변경 (비공개 GCS 버킷 대응)
- getPhotoUrls(): 공개 URL → GoogleCloudStorageService.getSignedUrl() 사용
- basic-info 탭: 동일하게 Signed URL로 변경
- URL 유효기간 120분
2026-02-25 21:13:30 +09:00
김보곤
bd8176b426 fix: [equipment] 사진 업로드 시 stored_name 누락 오류 수정
- files 테이블 stored_name 컬럼이 NOT NULL인데 INSERT에서 누락
- uploadPhotos(), uploadPhotoFromPath() 모두 stored_name 추가
2026-02-25 21:07:56 +09:00
김보곤
651ee2ef61 fix: [equipment] Ctrl+V 붙여넣기 클립보드 이미지 추출 수정
- DataTransferItemList 전통적 for 루프로 변경 (for...of 호환성 문제)
- kind === 'file' 조건 추가로 이미지 파일만 정확히 필터링
2026-02-25 20:58:32 +09:00
김보곤
58af12f08d feat: [equipment] 사진 업로드에 Ctrl+V 클립보드 붙여넣기 기능 추가
- create/edit 페이지에서 Ctrl+V로 클립보드 이미지 즉시 업로드
- 드롭존에 붙여넣기 안내 텍스트 추가
2026-02-25 20:56:11 +09:00
김보곤
308462dd69 feat: [equipment] 엑셀 Import 시 설비 사진 추출/업로드 기능 추가
- EquipmentPhotoService: uploadPhotoFromPath() 추가, 압축 메서드 public 전환
- EquipmentImportService: Drawing 추출/임시파일 저장/사진 업로드 통합
- EquipmentController: Import 응답 메시지에 사진 업로드 결과 포함
2026-02-25 20:49:58 +09:00
김보곤
e291b29bd7 feat: [equipment] 설비 사진 업로드 시 이미지 압축 (1MB 이하)
- GD 라이브러리로 업로드 전 이미지 압축 처리
- 장축 2048px 초과 시 리사이즈 (비율 유지)
- JPEG 품질 85→40 점진적 감소로 1MB 이하 보장
- PNG(투명 없음)/GIF/BMP → JPEG 자동 변환
- PNG(투명 있음) → PNG 유지 (압축 레벨 9)
- 임시파일 자동 정리 (finally 블록)
2026-02-25 20:38:37 +09:00
김보곤
7f1327bfea feat: [equipment] 사진 멀티 업로드(GCS) + 엑셀 Import 기능 추가
- EquipmentPhotoService: GCS 기반 사진 업로드/삭제/조회 (최대 10장)
- EquipmentImportService: 엑셀 파싱 → 설비 일괄 등록 (한글 헤더 자동 매핑)
- API: 사진 업로드/목록/삭제, Import 미리보기/실행 엔드포인트
- 뷰: create/edit에 드래그앤드롭 사진 업로드, show에 갤러리 표시
- import.blade.php: 3단계 Import UI (파일선택 → 미리보기 → 결과)
- phpoffice/phpspreadsheet 패키지 추가
2026-02-25 20:15:06 +09:00
김보곤
a3668354d9 fix: [equipment] API URL 경로 /api 접두사 누락 수정
- 모든 HTMX hx-get 및 fetch() URL에 /api 접두사 추가
- /admin/equipment → /api/admin/equipment 일괄 변경
- 대상: index, create, edit, show, inspections, repairs 뷰 7개 파일
2026-02-25 19:46:12 +09:00
김보곤
4115bbd7db feat: [equipment] 설비관리 모듈 구현
- 모델 6개 (Equipment, InspectionTemplate, Inspection, InspectionDetail, Repair, Process)
- 서비스 3개 (Equipment, Inspection, Repair)
- API 컨트롤러 3개 + FormRequest 4개
- Blade 컨트롤러 + 라우트 등록
- 뷰: 대시보드, 등록대장(CRUD), 일상점검표(캘린더 그리드), 수리이력
2026-02-25 19:39:59 +09:00
31246e3317 fix: [sync] config:cache 환경에서 동기화 API 401 오류 수정
menu_sync_api_key를 config/app.php에 등록하여 config:cache 후에도
env() 값이 정상 반환되도록 수정. 컨트롤러에서 env() 직접 호출 제거.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:57:04 +09:00
김보곤
5e1a093476 fix: [academy] 경정청구 페이지 이미지 hover 확대 효과 복원 2026-02-25 16:46:41 +09:00
김보곤
70e63edfa8 feat: [academy] 경정청구 페이지 UI/UX 개선
- CSS 애니메이션 시스템 추가 (fadeUp, popIn, float, drawLine 등 @keyframes)
- 히어로 배너 SVG 일러스트 업그레이드 (세금문서→SAM→환급 플로우)
- 섹션별 SVG 인포그래픽 5종 추가 (플로우차트, Before/After, 타임라인, 효익 아이콘, SAM 아키텍처)
- IntersectionObserver 기반 스크롤 애니메이션 (AOS)
- KPI 카운트업 애니메이션 (requestAnimationFrame easeOut)
- TOC 활성 섹션 하이라이트
- 프로세스 토글 CSS 트랜지션 적용
- 미사용 데드코드 제거 (lightbox, hover-preview DOM/CSS/JS)
2026-02-25 16:45:07 +09:00
김보곤
1416b4600c feat: [academy] 경정청구 메뉴 추가
- 아카데미 하위에 경정청구 페이지 신규 생성
- sales 경정청구 자료를 MNG 아카데미 패턴으로 변환
- SAM 제안 형태: 청년 판정, 권역 판정, 데이터 통합 자동화
- Chart.js 차트 4종 포함 (소요시간, 비용절감, 오류유형, 이용자추이)
- 학습 가이드 (용어 해설 + 퀴즈 5문항)
2026-02-25 16:20:29 +09:00
391 changed files with 93502 additions and 549 deletions

3
.gitignore vendored
View File

@@ -162,3 +162,6 @@ package-lock.json
# 아카데미 SVG 일러스트는 프로젝트 자산으로 추적
!public/images/academy/**/*.svg
# 다운로드용 PPTX 파일은 프로젝트 자산으로 추적
!public/downloads/*.pptx

View File

@@ -12,4 +12,4 @@ ## 최근 커밋 이력 (참고용)
## 다음 단계 (필요 시)
- API Explorer Phase 2-5 (API 실행, 즐겨찾기, 히스토리, UX 개선)
- MNG 견적수식 관리 UI 개발 (`docs/plans/mng-quote-formula-development-plan.md`)
- MNG 견적수식 관리 UI 개발 (`docs/dev/dev_plans/mng-quote-formula-development-plan.md`)

8
Jenkinsfile vendored
View File

@@ -17,7 +17,7 @@ pipeline {
script {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
}
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_mng', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *mng* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
@@ -43,7 +43,9 @@ pipeline {
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage/logs &&
ln -sfn /home/webservice/mng/shared/.env .env &&
sudo chmod 640 /home/webservice/mng/shared/.env &&
ln -sfn /home/webservice/mng/shared/storage/app storage/app &&
ln -sfn /home/webservice/mng/shared/storage/credentials storage/credentials &&
composer install --no-dev --optimize-autoloader --no-interaction &&
npm install --prefer-offline &&
npm run build &&
@@ -65,11 +67,11 @@ pipeline {
post {
success {
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_mng', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *mng* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_mng', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *mng* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
script {
if (env.BRANCH_NAME == 'main') {

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Services\HR\AttendanceService;
use Illuminate\Console\Command;
class MarkAbsentEmployees extends Command
{
protected $signature = 'attendance:mark-absent {--date= : 대상 날짜 (YYYY-MM-DD), 기본값: 오늘}';
protected $description = '영업일에 출근 기록이 없는 사원을 자동 결근 처리';
public function handle(AttendanceService $service): int
{
$date = $this->option('date') ?: now()->toDateString();
$this->info("자동 결근 처리 시작: {$date}");
$count = $service->markAbsentees($date);
if ($count > 0) {
$this->info("{$count}명 결근 처리 완료");
} else {
$this->info('결근 처리 대상이 없습니다 (주말이거나 모든 사원에 기록이 있음)');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,266 @@
<?php
namespace App\Enums;
use Carbon\Carbon;
class InspectionCycle
{
const DAILY = 'daily';
const WEEKLY = 'weekly';
const MONTHLY = 'monthly';
const BIMONTHLY = 'bimonthly';
const QUARTERLY = 'quarterly';
const SEMIANNUAL = 'semiannual';
/**
* 전체 주기 목록 (코드 → 라벨)
*/
public static function all(): array
{
return [
self::DAILY => '일일',
self::WEEKLY => '주간',
self::MONTHLY => '월간',
self::BIMONTHLY => '2개월',
self::QUARTERLY => '분기',
self::SEMIANNUAL => '반년',
];
}
/**
* 주기별 라벨 반환
*/
public static function label(string $cycle): string
{
return self::all()[$cycle] ?? $cycle;
}
/**
* 주기별 기간 필터 타입 반환
*/
public static function periodType(string $cycle): string
{
return $cycle === self::DAILY ? 'month' : 'year';
}
/**
* 주기별 그리드 열 라벨 반환
*
* @return array<int, string> [colIndex => label]
*/
public static function columnLabels(string $cycle, ?string $period = null): array
{
return match ($cycle) {
self::DAILY => self::dailyLabels($period),
self::WEEKLY => self::weeklyLabels(),
self::MONTHLY => self::monthlyLabels(),
self::BIMONTHLY => self::bimonthlyLabels(),
self::QUARTERLY => self::quarterlyLabels(),
self::SEMIANNUAL => self::semiannualLabels(),
default => self::dailyLabels($period),
};
}
/**
* 열 인덱스 → check_date 변환
*/
public static function resolveCheckDate(string $cycle, string $period, int $colIndex): string
{
return match ($cycle) {
self::DAILY => self::dailyCheckDate($period, $colIndex),
self::WEEKLY => self::weeklyCheckDate($period, $colIndex),
self::MONTHLY => self::monthlyCheckDate($period, $colIndex),
self::BIMONTHLY => self::bimonthlyCheckDate($period, $colIndex),
self::QUARTERLY => self::quarterlyCheckDate($period, $colIndex),
self::SEMIANNUAL => self::semiannualCheckDate($period, $colIndex),
default => self::dailyCheckDate($period, $colIndex),
};
}
/**
* check_date → year_month(period) 역산
*/
public static function resolvePeriod(string $cycle, string $checkDate): string
{
$date = Carbon::parse($checkDate);
return match ($cycle) {
self::DAILY => $date->format('Y-m'),
self::WEEKLY => (string) $date->isoWeekYear,
default => $date->format('Y'),
};
}
/**
* 주기별 그리드 열 수
*/
public static function columnCount(string $cycle, ?string $period = null): int
{
return count(self::columnLabels($cycle, $period));
}
/**
* 주말 여부 (daily 전용)
*/
public static function isWeekend(string $period, int $colIndex): bool
{
$date = Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1);
return in_array($date->dayOfWeek, [0, 6]);
}
/**
* 해당 기간의 휴일 날짜 목록 반환 (date string Set)
*/
public static function getHolidayDates(string $cycle, string $period): array
{
$tenantId = session('selected_tenant_id', 1);
if ($cycle === self::DAILY) {
$start = Carbon::createFromFormat('Y-m', $period)->startOfMonth();
$end = $start->copy()->endOfMonth();
} else {
$start = Carbon::create((int) $period, 1, 1);
$end = Carbon::create((int) $period, 12, 31);
}
$holidays = \App\Models\System\Holiday::where('tenant_id', $tenantId)
->where('start_date', '<=', $end->toDateString())
->where('end_date', '>=', $start->toDateString())
->get();
$dates = [];
foreach ($holidays as $holiday) {
$hStart = $holiday->start_date->copy()->max($start);
$hEnd = $holiday->end_date->copy()->min($end);
$current = $hStart->copy();
while ($current->lte($hEnd)) {
$dates[$current->format('Y-m-d')] = true;
$current->addDay();
}
}
return $dates;
}
/**
* 비근무일 여부 (주말 또는 휴일)
*/
public static function isNonWorkingDay(string $checkDate, array $holidayDates = []): bool
{
$date = Carbon::parse($checkDate);
return $date->isWeekend() || isset($holidayDates[$checkDate]);
}
// --- Daily ---
private static function dailyLabels(?string $period): array
{
$date = Carbon::createFromFormat('Y-m', $period ?? now()->format('Y-m'));
$days = $date->daysInMonth;
$labels = [];
for ($d = 1; $d <= $days; $d++) {
$labels[$d] = (string) $d;
}
return $labels;
}
private static function dailyCheckDate(string $period, int $colIndex): string
{
return Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1)->format('Y-m-d');
}
// --- Weekly ---
private static function weeklyLabels(): array
{
$labels = [];
for ($w = 1; $w <= 52; $w++) {
$labels[$w] = $w.'주';
}
return $labels;
}
private static function weeklyCheckDate(string $year, int $colIndex): string
{
// ISO 주차의 월요일
return Carbon::create((int) $year)->setISODate((int) $year, $colIndex, 1)->format('Y-m-d');
}
// --- Monthly ---
private static function monthlyLabels(): array
{
$labels = [];
for ($m = 1; $m <= 12; $m++) {
$labels[$m] = $m.'월';
}
return $labels;
}
private static function monthlyCheckDate(string $year, int $colIndex): string
{
return Carbon::create((int) $year, $colIndex, 1)->format('Y-m-d');
}
// --- Bimonthly ---
private static function bimonthlyLabels(): array
{
return [
1 => '1~2월',
2 => '3~4월',
3 => '5~6월',
4 => '7~8월',
5 => '9~10월',
6 => '11~12월',
];
}
private static function bimonthlyCheckDate(string $year, int $colIndex): string
{
$month = ($colIndex - 1) * 2 + 1;
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
}
// --- Quarterly ---
private static function quarterlyLabels(): array
{
return [
1 => '1분기',
2 => '2분기',
3 => '3분기',
4 => '4분기',
];
}
private static function quarterlyCheckDate(string $year, int $colIndex): string
{
$month = ($colIndex - 1) * 3 + 1;
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
}
// --- Semiannual ---
private static function semiannualLabels(): array
{
return [
1 => '상반기',
2 => '하반기',
];
}
private static function semiannualCheckDate(string $year, int $colIndex): string
{
$month = $colIndex === 1 ? 1 : 7;
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
}
}

View File

@@ -133,4 +133,13 @@ public function pm2Guide(Request $request): View|Response
return view('academy.pm2-guide');
}
public function taxCorrection(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('academy.tax-correction'));
}
return view('academy.tax-correction');
}
}

View File

@@ -19,7 +19,7 @@ class PptxController extends Controller
'/var/www/docs/rules' => ['label' => '정책/규칙', 'source' => 'docs'],
'/var/www/docs/guides' => ['label' => '가이드', 'source' => 'docs'],
'/var/www/docs/projects' => ['label' => '프로젝트', 'source' => 'docs'],
'/var/www/docs/plans' => ['label' => '계획', 'source' => 'docs'],
'/var/www/docs/dev_plans' => ['label' => '계획', 'source' => 'docs'],
'/var/www/mng/docs/pptx-output' => ['label' => '산출물', 'source' => 'mng'],
'/var/www/mng/docs' => ['label' => '교육/문서', 'source' => 'mng'],
'/var/www/mng/public/docs' => ['label' => '공개 문서', 'source' => 'mng'],

View File

@@ -0,0 +1,841 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Boards\File;
use App\Services\AppointmentCertService;
use App\Services\ApprovalService;
use App\Services\CareerCertService;
use App\Services\EmploymentCertService;
use App\Services\GoogleCloudStorageService;
use App\Services\ResignationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ApprovalApiController extends Controller
{
public function __construct(
private readonly ApprovalService $service
) {}
// =========================================================================
// 목록
// =========================================================================
/**
* 기안함
*/
public function drafts(Request $request): JsonResponse
{
$result = $this->service->getMyDrafts(
$request->only(['search', 'status', 'is_urgent', 'date_from', 'date_to']),
(int) $request->get('per_page', 15)
);
return response()->json($result);
}
/**
* 결재 대기함
*/
public function pending(Request $request): JsonResponse
{
$result = $this->service->getPendingForMe(
auth()->id(),
$request->only(['search', 'is_urgent', 'date_from', 'date_to']),
(int) $request->get('per_page', 15)
);
return response()->json($result);
}
/**
* 처리 완료함
*/
public function completed(Request $request): JsonResponse
{
$result = $this->service->getCompletedByMe(
auth()->id(),
$request->only(['search', 'status', 'date_from', 'date_to']),
(int) $request->get('per_page', 15)
);
return response()->json($result);
}
/**
* 참조함
*/
public function references(Request $request): JsonResponse
{
$result = $this->service->getReferencesForMe(
auth()->id(),
$request->only(['search', 'date_from', 'date_to', 'is_read']),
(int) $request->get('per_page', 15)
);
return response()->json($result);
}
// =========================================================================
// CRUD
// =========================================================================
/**
* 상세 조회
*/
public function show(int $id): JsonResponse
{
$approval = $this->service->getApproval($id);
return response()->json(['success' => true, 'data' => $approval]);
}
/**
* 생성 (임시저장)
*/
public function store(Request $request): JsonResponse
{
$request->validate([
'form_id' => 'required|exists:approval_forms,id',
'title' => 'required|string|max:200',
'body' => 'nullable|string',
'content' => 'nullable|array',
'is_urgent' => 'boolean',
'steps' => 'nullable|array',
'steps.*.user_id' => 'required_with:steps|exists:users,id',
'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference',
'attachment_file_ids' => 'nullable|array',
'attachment_file_ids.*' => 'integer',
]);
$approval = $this->service->createApproval($request->all());
return response()->json([
'success' => true,
'message' => '결재 문서가 저장되었습니다.',
'data' => $approval,
], 201);
}
/**
* 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$request->validate([
'title' => 'sometimes|string|max:200',
'body' => 'nullable|string',
'content' => 'nullable|array',
'is_urgent' => 'boolean',
'steps' => 'nullable|array',
'steps.*.user_id' => 'required_with:steps|exists:users,id',
'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference',
'attachment_file_ids' => 'nullable|array',
'attachment_file_ids.*' => 'integer',
]);
try {
$approval = $this->service->updateApproval($id, $request->all());
return response()->json([
'success' => true,
'message' => '결재 문서가 수정되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 삭제
*/
public function destroy(int $id): JsonResponse
{
try {
$user = auth()->user();
$approval = $this->service->getApproval($id);
if (! $approval->isDeletableBy($user)) {
return response()->json([
'success' => false,
'message' => '삭제 권한이 없습니다.',
], 403);
}
$this->service->deleteApproval($id, $user);
return response()->json([
'success' => true,
'message' => '결재 문서가 삭제되었습니다.',
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 영구삭제 (슈퍼관리자 전용)
*/
public function forceDestroy(int $id): JsonResponse
{
if (! auth()->user()->isSuperAdmin()) {
return response()->json([
'success' => false,
'message' => '슈퍼관리자만 영구삭제할 수 있습니다.',
], 403);
}
try {
$this->service->forceDeleteApproval($id);
return response()->json([
'success' => true,
'message' => '결재 문서가 영구삭제되었습니다.',
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '영구삭제에 실패했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 선택삭제 (기안자 본인 문서만)
*/
public function bulkDestroy(Request $request): JsonResponse
{
$ids = $request->input('ids', []);
if (empty($ids) || ! is_array($ids)) {
return response()->json([
'success' => false,
'message' => '삭제할 문서를 선택하세요.',
], 400);
}
$user = auth()->user();
$deleted = 0;
$failed = 0;
foreach ($ids as $id) {
try {
$approval = $this->service->getApproval($id);
if ($approval->isDeletableBy($user)) {
$this->service->deleteApproval($id, $user);
$deleted++;
} else {
$failed++;
}
} catch (\Throwable) {
$failed++;
}
}
$message = "{$deleted}건 삭제 완료";
if ($failed > 0) {
$message .= " ({$failed}건 삭제 불가)";
}
return response()->json([
'success' => true,
'message' => $message,
'deleted' => $deleted,
'failed' => $failed,
]);
}
// =========================================================================
// 재직증명서
// =========================================================================
/**
* 사원 재직증명서 정보 조회
*/
public function certInfo(int $userId): JsonResponse
{
try {
$tenantId = session('selected_tenant_id');
$service = app(EmploymentCertService::class);
$data = $service->getCertInfo($userId, $tenantId);
return response()->json(['success' => true, 'data' => $data]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'message' => '사원 정보를 불러올 수 없습니다.',
], 400);
}
}
/**
* 재직증명서 PDF 다운로드 (content JSON 기반 HTML→PDF)
*/
public function certPdf(int $id)
{
$approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id'))
->findOrFail($id);
$content = $approval->content ?? [];
$service = app(EmploymentCertService::class);
return $service->generatePdfResponse($content);
}
// =========================================================================
// 경력증명서
// =========================================================================
/**
* 사원 경력증명서 정보 조회
*/
public function careerCertInfo(int $userId): JsonResponse
{
try {
$tenantId = session('selected_tenant_id');
$service = app(CareerCertService::class);
$data = $service->getCertInfo($userId, $tenantId);
return response()->json(['success' => true, 'data' => $data]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'message' => '사원 정보를 불러올 수 없습니다.',
], 400);
}
}
/**
* 경력증명서 PDF 다운로드
*/
public function careerCertPdf(int $id)
{
$approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id'))
->findOrFail($id);
$content = $approval->content ?? [];
$service = app(CareerCertService::class);
return $service->generatePdfResponse($content);
}
// =========================================================================
// 위촉증명서
// =========================================================================
/**
* 사원 위촉증명서 정보 조회
*/
public function appointmentCertInfo(int $userId): JsonResponse
{
try {
$tenantId = session('selected_tenant_id');
$service = app(AppointmentCertService::class);
$data = $service->getCertInfo($userId, $tenantId);
return response()->json(['success' => true, 'data' => $data]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'message' => '사원 정보를 불러올 수 없습니다.',
], 400);
}
}
/**
* 위촉증명서 PDF 다운로드
*/
public function appointmentCertPdf(int $id)
{
$approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id'))
->findOrFail($id);
$content = $approval->content ?? [];
$service = app(AppointmentCertService::class);
return $service->generatePdfResponse($content);
}
// =========================================================================
// 사직서
// =========================================================================
/**
* 사원 사직서 정보 조회
*/
public function resignationInfo(int $userId): JsonResponse
{
try {
$tenantId = session('selected_tenant_id');
$service = app(ResignationService::class);
$data = $service->getCertInfo($userId, $tenantId);
return response()->json(['success' => true, 'data' => $data]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'message' => '사원 정보를 불러올 수 없습니다.',
], 400);
}
}
/**
* 사직서 PDF 다운로드
*/
public function resignationPdf(int $id)
{
$approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id'))
->findOrFail($id);
$content = $approval->content ?? [];
$service = app(ResignationService::class);
return $service->generatePdfResponse($content);
}
// =========================================================================
// 워크플로우
// =========================================================================
/**
* 상신
*/
public function submit(int $id): JsonResponse
{
try {
$approval = $this->service->submit($id);
return response()->json([
'success' => true,
'message' => '결재가 상신되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 승인
*/
public function approve(Request $request, int $id): JsonResponse
{
try {
$approval = $this->service->approve($id, $request->get('comment'));
return response()->json([
'success' => true,
'message' => '승인되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 반려
*/
public function reject(Request $request, int $id): JsonResponse
{
$request->validate([
'comment' => 'required|string|max:1000',
]);
try {
$approval = $this->service->reject($id, $request->get('comment'));
return response()->json([
'success' => true,
'message' => '반려되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 회수
*/
public function cancel(Request $request, int $id): JsonResponse
{
try {
$approval = $this->service->cancel($id, $request->get('recall_reason'));
return response()->json([
'success' => true,
'message' => '결재가 회수되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 보류
*/
public function hold(Request $request, int $id): JsonResponse
{
$request->validate([
'comment' => 'required|string|max:1000',
]);
try {
$approval = $this->service->hold($id, $request->get('comment'));
return response()->json([
'success' => true,
'message' => '보류되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 보류 해제
*/
public function releaseHold(int $id): JsonResponse
{
try {
$approval = $this->service->releaseHold($id);
return response()->json([
'success' => true,
'message' => '보류가 해제되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 전결
*/
public function preDecide(Request $request, int $id): JsonResponse
{
try {
$approval = $this->service->preDecide($id, $request->get('comment'));
return response()->json([
'success' => true,
'message' => '전결 처리되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 복사 재기안
*/
public function copyForRedraft(int $id): JsonResponse
{
try {
$approval = $this->service->copyForRedraft($id);
return response()->json([
'success' => true,
'message' => '문서가 복사되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 참조 열람 추적
*/
public function markAsRead(int $id): JsonResponse
{
$this->service->markAsRead($id);
return response()->json([
'success' => true,
'message' => '열람 처리되었습니다.',
]);
}
// =========================================================================
// 유틸
// =========================================================================
/**
* 결재선 템플릿 목록
*/
public function lines(): JsonResponse
{
$lines = $this->service->getApprovalLines();
return response()->json(['success' => true, 'data' => $lines]);
}
/**
* 결재선 템플릿 생성
*/
public function storeLine(Request $request): JsonResponse
{
$request->validate([
'name' => 'required|string|max:100',
'steps' => 'required|array|min:1',
'steps.*.user_id' => 'required|exists:users,id',
'steps.*.step_type' => 'required|in:approval,agreement,reference',
'is_default' => 'boolean',
]);
$line = $this->service->createLine($request->all());
return response()->json([
'success' => true,
'message' => '결재선이 저장되었습니다.',
'data' => $line,
], 201);
}
/**
* 결재선 템플릿 수정
*/
public function updateLine(Request $request, int $id): JsonResponse
{
$request->validate([
'name' => 'required|string|max:100',
'steps' => 'required|array|min:1',
'steps.*.user_id' => 'required|exists:users,id',
'steps.*.step_type' => 'required|in:approval,agreement,reference',
'is_default' => 'boolean',
]);
$line = $this->service->updateLine($id, $request->all());
return response()->json([
'success' => true,
'message' => '결재선이 수정되었습니다.',
'data' => $line,
]);
}
/**
* 결재선 템플릿 삭제
*/
public function destroyLine(int $id): JsonResponse
{
$this->service->deleteLine($id);
return response()->json([
'success' => true,
'message' => '결재선이 삭제되었습니다.',
]);
}
/**
* 지출결의서 이력 (불러오기용)
*/
public function expenseHistory(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id');
$approvals = \App\Models\Approvals\Approval::where('tenant_id', $tenantId)
->where('drafter_id', auth()->id())
->whereHas('form', fn ($q) => $q->where('code', 'expense'))
->whereIn('status', ['draft', 'pending', 'approved', 'rejected', 'cancelled'])
->whereNotNull('content')
->orderByDesc('created_at')
->limit(30)
->get(['id', 'title', 'content', 'status', 'created_at']);
$data = $approvals->map(fn ($a) => [
'id' => $a->id,
'title' => $a->title,
'status' => $a->status,
'status_label' => $a->status_label,
'total_amount' => $a->content['total_amount'] ?? 0,
'expense_type' => $a->content['expense_type'] ?? '',
'created_at' => $a->created_at->format('Y-m-d'),
'content' => $a->content,
]);
return response()->json(['success' => true, 'data' => $data]);
}
/**
* 양식 목록
*/
public function forms(): JsonResponse
{
$forms = $this->service->getApprovalForms();
return response()->json(['success' => true, 'data' => $forms]);
}
/**
* 미처리 건수
*/
public function badgeCounts(): JsonResponse
{
$counts = $this->service->getBadgeCounts(auth()->id());
return response()->json(['success' => true, 'data' => $counts]);
}
/**
* 완료함 읽음 처리 (일괄)
*/
public function markCompletedAsRead(): JsonResponse
{
$count = $this->service->markCompletedAsRead(auth()->id());
return response()->json([
'success' => true,
'message' => $count > 0 ? "{$count}건 읽음 처리되었습니다." : '새로운 완료 건이 없습니다.',
'data' => ['marked_count' => $count],
]);
}
/**
* 개별 문서 기안자 읽음 처리
*/
public function markReadSingle(int $id): JsonResponse
{
$approval = $this->service->getApproval($id);
if ($approval->drafter_id === auth()->id() && ! $approval->drafter_read_at) {
$approval->update(['drafter_read_at' => now()]);
}
return response()->json(['success' => true]);
}
// =========================================================================
// 첨부파일
// =========================================================================
/**
* 첨부파일 업로드
*/
public function uploadFile(Request $request, GoogleCloudStorageService $gcs): JsonResponse
{
$request->validate([
'file' => 'required|file|max:20480',
]);
$file = $request->file('file');
$tenantId = session('selected_tenant_id');
$storedName = Str::random(40).'.'.$file->getClientOriginalExtension();
$storagePath = "approvals/{$tenantId}/{$storedName}";
Storage::disk('tenant')->put($storagePath, file_get_contents($file));
$gcsUri = null;
$gcsObjectName = null;
if ($gcs->isAvailable()) {
$gcsObjectName = $storagePath;
$gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName);
}
$fileRecord = File::create([
'tenant_id' => $tenantId,
'document_type' => 'approval_attachment',
'display_name' => $file->getClientOriginalName(),
'original_name' => $file->getClientOriginalName(),
'stored_name' => $storedName,
'file_path' => $storagePath,
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
'file_type' => strtolower($file->getClientOriginalExtension()),
'gcs_object_name' => $gcsObjectName,
'gcs_uri' => $gcsUri,
'is_temp' => true,
'uploaded_by' => auth()->id(),
'created_by' => auth()->id(),
]);
return response()->json([
'success' => true,
'data' => [
'id' => $fileRecord->id,
'name' => $fileRecord->original_name,
'size' => $fileRecord->file_size,
'mime_type' => $fileRecord->mime_type,
],
]);
}
/**
* 첨부파일 삭제
*/
public function deleteFile(int $fileId): JsonResponse
{
$file = File::where('id', $fileId)
->where('uploaded_by', auth()->id())
->first();
if (! $file) {
return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404);
}
if ($file->existsInStorage()) {
Storage::disk('tenant')->delete($file->file_path);
}
$file->forceDelete();
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
}
/**
* 첨부파일 다운로드
*/
public function downloadFile(int $fileId)
{
$file = File::findOrFail($fileId);
if (Storage::disk('tenant')->exists($file->file_path)) {
return Storage::disk('tenant')->download($file->file_path, $file->original_name);
}
abort(404, '파일을 찾을 수 없습니다.');
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Api\Admin\Barobill;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillMember;
use App\Services\Barobill\BarobillService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BarobillSmsController extends Controller
{
public function __construct(
protected BarobillService $barobillService
) {}
/**
* 회원사 조회 및 서버 모드 전환 헬퍼
*/
private function resolveMember(): BarobillMember|JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$member = BarobillMember::where('tenant_id', $tenantId)->first();
if (! $member) {
return response()->json([
'success' => false,
'message' => '바로빌 회원사가 등록되어 있지 않습니다.',
], 404);
}
$this->barobillService->setServerMode($member->server_mode ?? 'test');
return $member;
}
/**
* SMS 발송
*/
public function sendSms(Request $request): JsonResponse
{
$validated = $request->validate([
'from_number' => 'required|string',
'to_name' => 'required|string',
'to_number' => 'required|string',
'contents' => 'required|string',
'send_dt' => 'nullable|string',
'ref_key' => 'nullable|string',
]);
$member = $this->resolveMember();
if ($member instanceof JsonResponse) {
return $member;
}
$result = $this->barobillService->sendSMSMessage(
corpNum: $member->biz_no,
senderId: $member->barobill_id,
fromNumber: $validated['from_number'],
toName: $validated['to_name'],
toNumber: $validated['to_number'],
contents: $validated['contents'],
sendDT: $validated['send_dt'] ?? '',
refKey: $validated['ref_key'] ?? ''
);
if (! $result['success']) {
return response()->json($result, 422);
}
return response()->json($result);
}
/**
* 등록된 발신번호 목록 조회
*/
public function getFromNumbers(): JsonResponse
{
$member = $this->resolveMember();
if ($member instanceof JsonResponse) {
return $member;
}
$result = $this->barobillService->getSMSFromNumbers($member->biz_no);
if (! $result['success']) {
return response()->json($result, 422);
}
return response()->json($result);
}
/**
* 발신번호 등록 여부 확인
*/
public function checkFromNumber(Request $request): JsonResponse
{
$validated = $request->validate([
'from_number' => 'required|string',
]);
$member = $this->resolveMember();
if ($member instanceof JsonResponse) {
return $member;
}
$result = $this->barobillService->checkSMSFromNumber(
$member->biz_no,
$validated['from_number']
);
if (! $result['success']) {
return response()->json($result, 422);
}
return response()->json($result);
}
/**
* SMS 전송 상태 조회
*/
public function getSendState(string $sendKey): JsonResponse
{
$member = $this->resolveMember();
if ($member instanceof JsonResponse) {
return $member;
}
$result = $this->barobillService->getSMSSendState($member->biz_no, $sendKey);
if (! $result['success']) {
return response()->json($result, 422);
}
return response()->json($result);
}
}

View File

@@ -127,6 +127,7 @@ public function store(Request $request): JsonResponse
$validated = $request->validate([
'name' => 'required|string|max:100',
'category' => 'nullable|string|max:50',
'builder_type' => 'nullable|string|in:legacy,block',
'title' => 'nullable|string|max:200',
'company_name' => 'nullable|string|max:100',
'company_address' => 'nullable|string|max:255',
@@ -134,6 +135,8 @@ public function store(Request $request): JsonResponse
'footer_remark_label' => 'nullable|string|max:50',
'footer_judgement_label' => 'nullable|string|max:50',
'footer_judgement_options' => 'nullable|array',
'schema' => 'nullable|array',
'page_config' => 'nullable|array',
'is_active' => 'boolean',
'linked_item_ids' => 'nullable|array',
'linked_item_ids.*' => 'integer',
@@ -162,6 +165,7 @@ public function store(Request $request): JsonResponse
'tenant_id' => session('selected_tenant_id'),
'name' => $validated['name'],
'category' => $validated['category'] ?? null,
'builder_type' => $validated['builder_type'] ?? 'legacy',
'title' => $validated['title'] ?? null,
'company_name' => $validated['company_name'] ?? '경동기업',
'company_address' => $validated['company_address'] ?? null,
@@ -169,6 +173,8 @@ public function store(Request $request): JsonResponse
'footer_remark_label' => $validated['footer_remark_label'] ?? '부적합 내용',
'footer_judgement_label' => $validated['footer_judgement_label'] ?? '종합판정',
'footer_judgement_options' => $validated['footer_judgement_options'] ?? ['적합', '부적합'],
'schema' => $validated['schema'] ?? null,
'page_config' => $validated['page_config'] ?? null,
'is_active' => $validated['is_active'] ?? true,
'linked_item_ids' => $validated['linked_item_ids'] ?? null,
'linked_process_id' => $validated['linked_process_id'] ?? null,
@@ -204,6 +210,7 @@ public function update(Request $request, int $id): JsonResponse
$validated = $request->validate([
'name' => 'required|string|max:100',
'category' => 'nullable|string|max:50',
'builder_type' => 'nullable|string|in:legacy,block',
'title' => 'nullable|string|max:200',
'company_name' => 'nullable|string|max:100',
'company_address' => 'nullable|string|max:255',
@@ -211,6 +218,8 @@ public function update(Request $request, int $id): JsonResponse
'footer_remark_label' => 'nullable|string|max:50',
'footer_judgement_label' => 'nullable|string|max:50',
'footer_judgement_options' => 'nullable|array',
'schema' => 'nullable|array',
'page_config' => 'nullable|array',
'is_active' => 'boolean',
'linked_item_ids' => 'nullable|array',
'linked_item_ids.*' => 'integer',
@@ -235,7 +244,7 @@ public function update(Request $request, int $id): JsonResponse
try {
DB::beginTransaction();
$template->update([
$updateData = [
'name' => $validated['name'],
'category' => $validated['category'] ?? null,
'title' => $validated['title'] ?? null,
@@ -248,7 +257,20 @@ public function update(Request $request, int $id): JsonResponse
'is_active' => $validated['is_active'] ?? true,
'linked_item_ids' => $validated['linked_item_ids'] ?? null,
'linked_process_id' => $validated['linked_process_id'] ?? null,
]);
];
// 블록 빌더 전용 필드
if (isset($validated['builder_type'])) {
$updateData['builder_type'] = $validated['builder_type'];
}
if (array_key_exists('schema', $validated)) {
$updateData['schema'] = $validated['schema'];
}
if (array_key_exists('page_config', $validated)) {
$updateData['page_config'] = $validated['page_config'];
}
$template->update($updateData);
// 관계 데이터 저장 (기존 데이터 삭제 후 재생성)
$this->saveRelations($template, $validated, true);
@@ -396,6 +418,7 @@ public function duplicate(Request $request, int $id): JsonResponse
'tenant_id' => $source->tenant_id,
'name' => $newName,
'category' => $source->category,
'builder_type' => $source->builder_type ?? 'legacy',
'title' => $source->title,
'company_name' => $source->company_name,
'company_address' => $source->company_address,
@@ -403,6 +426,8 @@ public function duplicate(Request $request, int $id): JsonResponse
'footer_remark_label' => $source->footer_remark_label,
'footer_judgement_label' => $source->footer_judgement_label,
'footer_judgement_options' => $source->footer_judgement_options,
'schema' => $source->schema,
'page_config' => $source->page_config,
'is_active' => false,
'linked_item_ids' => null, // 연결품목은 복사하지 않음 (중복 방지)
'linked_process_id' => null,

View File

@@ -0,0 +1,265 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEquipmentRequest;
use App\Http\Requests\UpdateEquipmentRequest;
use App\Services\EquipmentImportService;
use App\Services\EquipmentPhotoService;
use App\Services\EquipmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentController extends Controller
{
public function __construct(
private EquipmentService $equipmentService,
private EquipmentPhotoService $photoService,
private EquipmentImportService $importService,
) {}
public function index(Request $request)
{
$equipments = $this->equipmentService->getEquipments(
$request->all(),
$request->input('per_page', 20)
);
if ($request->header('HX-Request')) {
return view('equipment.partials.table', compact('equipments'));
}
return response()->json([
'success' => true,
'data' => $equipments->items(),
'meta' => [
'current_page' => $equipments->currentPage(),
'total' => $equipments->total(),
'per_page' => $equipments->perPage(),
'last_page' => $equipments->lastPage(),
],
]);
}
public function store(StoreEquipmentRequest $request): JsonResponse
{
try {
$equipment = $this->equipmentService->createEquipment($request->validated());
return response()->json([
'success' => true,
'message' => '설비가 등록되었습니다.',
'data' => $equipment,
], 201);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function show(int $id): JsonResponse
{
$equipment = $this->equipmentService->getEquipmentById($id);
if (! $equipment) {
return response()->json([
'success' => false,
'message' => '설비를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $equipment,
]);
}
public function update(UpdateEquipmentRequest $request, int $id): JsonResponse
{
try {
$equipment = $this->equipmentService->updateEquipment($id, $request->validated());
return response()->json([
'success' => true,
'message' => '설비 정보가 수정되었습니다.',
'data' => $equipment,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function destroy(int $id): JsonResponse
{
try {
$this->equipmentService->deleteEquipment($id);
return response()->json([
'success' => true,
'message' => '설비가 삭제되었습니다.',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function restore(int $id): JsonResponse
{
try {
$this->equipmentService->restoreEquipment($id);
return response()->json([
'success' => true,
'message' => '설비가 복원되었습니다.',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function templates(int $id): JsonResponse
{
$equipment = $this->equipmentService->getEquipmentById($id);
if (! $equipment) {
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
}
return response()->json([
'success' => true,
'data' => $equipment->inspectionTemplates,
]);
}
// =========================================================================
// 사진 관리
// =========================================================================
public function uploadPhotos(Request $request, int $id): JsonResponse
{
$request->validate([
'photos' => 'required|array|min:1|max:10',
'photos.*' => 'required|image|max:10240',
]);
$equipment = $this->equipmentService->getEquipmentById($id);
if (! $equipment) {
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
}
$result = $this->photoService->uploadPhotos($equipment, $request->file('photos'));
return response()->json([
'success' => true,
'message' => "{$result['uploaded']}장 업로드 완료",
'data' => [
'uploaded' => $result['uploaded'],
'errors' => $result['errors'],
'photos' => $this->photoService->getPhotoUrls($equipment),
],
]);
}
public function photos(int $id): JsonResponse
{
$equipment = $this->equipmentService->getEquipmentById($id);
if (! $equipment) {
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
}
return response()->json([
'success' => true,
'data' => $this->photoService->getPhotoUrls($equipment),
]);
}
public function deletePhoto(int $id, int $fileId): JsonResponse
{
$equipment = $this->equipmentService->getEquipmentById($id);
if (! $equipment) {
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
}
$deleted = $this->photoService->deletePhoto($equipment, $fileId);
if (! $deleted) {
return response()->json(['success' => false, 'message' => '사진을 찾을 수 없습니다.'], 404);
}
return response()->json([
'success' => true,
'message' => '사진이 삭제되었습니다.',
]);
}
// =========================================================================
// 엑셀 Import
// =========================================================================
public function importPreview(Request $request): JsonResponse
{
$request->validate([
'file' => 'required|file|mimes:xlsx,xls|max:10240',
]);
try {
$result = $this->importService->preview($request->file('file')->getRealPath());
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function importExecute(Request $request): JsonResponse
{
$request->validate([
'file' => 'required|file|mimes:xlsx,xls|max:10240',
'duplicate_action' => 'in:skip,overwrite',
]);
try {
$result = $this->importService->import(
$request->file('file')->getRealPath(),
['duplicate_action' => $request->input('duplicate_action', 'skip')]
);
$photoMsg = '';
if (! empty($result['photos_uploaded'])) {
$photoMsg = ", 사진 {$result['photos_uploaded']}장 업로드";
}
return response()->json([
'success' => true,
'message' => "Import 완료: 성공 {$result['success']}건, 실패 {$result['failed']}건, 건너뜀 {$result['skipped']}{$photoMsg}",
'data' => $result,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
}

View File

@@ -0,0 +1,303 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Enums\InspectionCycle;
use App\Http\Controllers\Controller;
use App\Services\EquipmentInspectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentInspectionController extends Controller
{
public function __construct(
private EquipmentInspectionService $inspectionService
) {}
public function index(Request $request)
{
$cycle = $request->input('cycle', InspectionCycle::DAILY);
$period = $request->input('period') ?? $request->input('year_month', now()->format('Y-m'));
// daily가 아닌 주기에서 period가 없으면 현재 연도
if ($cycle !== InspectionCycle::DAILY && ! $request->input('period')) {
$period = now()->format('Y');
}
$productionLine = $request->input('production_line');
$equipmentId = $request->input('equipment_id');
$inspections = $this->inspectionService->getInspections(
$cycle,
$period,
$productionLine,
$equipmentId ? (int) $equipmentId : null
);
if ($request->header('HX-Request')) {
$holidayDates = InspectionCycle::getHolidayDates($cycle, $period);
return view('equipment.partials.inspection-grid', [
'inspections' => $inspections,
'cycle' => $cycle,
'period' => $period,
'holidayDates' => $holidayDates,
]);
}
return response()->json([
'success' => true,
'data' => $inspections,
]);
}
public function toggleDetail(Request $request): JsonResponse
{
$request->validate([
'equipment_id' => 'required|integer',
'template_item_id' => 'required|integer',
'check_date' => 'required|date',
'cycle' => 'nullable|string',
]);
try {
$result = $this->inspectionService->toggleDetail(
$request->input('equipment_id'),
$request->input('template_item_id'),
$request->input('check_date'),
$request->input('cycle', InspectionCycle::DAILY)
);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (\Exception $e) {
$status = $e->getMessage() === '점검 권한이 없습니다.' ? 403 : 400;
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], $status);
}
}
public function setResult(Request $request): JsonResponse
{
$request->validate([
'equipment_id' => 'required|integer',
'template_item_id' => 'required|integer',
'check_date' => 'required|date',
'cycle' => 'nullable|string',
'result' => 'nullable|in:good,bad,repaired',
]);
try {
$result = $this->inspectionService->setResult(
$request->input('equipment_id'),
$request->input('template_item_id'),
$request->input('check_date'),
$request->input('cycle', InspectionCycle::DAILY),
$request->input('result')
);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (\Exception $e) {
$status = $e->getMessage() === '점검 권한이 없습니다.' ? 403 : 400;
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], $status);
}
}
public function updateNotes(Request $request): JsonResponse
{
$request->validate([
'equipment_id' => 'required|integer',
'year_month' => 'required|string',
'cycle' => 'nullable|string',
'overall_judgment' => 'nullable|in:OK,NG',
'repair_note' => 'nullable|string',
'issue_note' => 'nullable|string',
'inspector_id' => 'nullable|integer',
]);
try {
$inspection = $this->inspectionService->updateInspectionNotes(
$request->input('equipment_id'),
$request->input('year_month'),
$request->only(['overall_judgment', 'repair_note', 'issue_note', 'inspector_id']),
$request->input('cycle', InspectionCycle::DAILY)
);
return response()->json([
'success' => true,
'message' => '점검 정보가 저장되었습니다.',
'data' => $inspection,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function resetInspection(Request $request): JsonResponse
{
$request->validate([
'equipment_id' => 'required|integer',
'cycle' => 'required|string',
'period' => 'required|string',
]);
try {
$deleted = $this->inspectionService->resetEquipmentInspection(
$request->input('equipment_id'),
$request->input('cycle'),
$request->input('period')
);
return response()->json([
'success' => true,
'message' => "점검 데이터 {$deleted}건이 초기화되었습니다.",
'data' => ['deleted' => $deleted],
]);
} catch (\Exception $e) {
$status = $e->getMessage() === '점검 권한이 없습니다.' ? 403 : 400;
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], $status);
}
}
public function resetAllInspections(Request $request): JsonResponse
{
$request->validate([
'cycle' => 'required|string',
'period' => 'required|string',
]);
try {
$deleted = $this->inspectionService->resetAllInspections(
$request->input('cycle'),
$request->input('period')
);
return response()->json([
'success' => true,
'message' => "전체 점검 데이터 {$deleted}건이 초기화되었습니다.",
'data' => ['deleted' => $deleted],
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function storeTemplate(Request $request, int $equipmentId): JsonResponse
{
$request->validate([
'item_no' => 'required|integer',
'inspection_cycle' => 'nullable|string',
'check_point' => 'required|string|max:50',
'check_item' => 'required|string|max:100',
'check_timing' => 'nullable|in:operating,stopped',
'check_frequency' => 'nullable|string|max:50',
'check_method' => 'nullable|string',
'sort_order' => 'nullable|integer',
]);
try {
$data = $request->all();
if (empty($data['inspection_cycle'])) {
$data['inspection_cycle'] = InspectionCycle::DAILY;
}
$template = $this->inspectionService->saveTemplate($equipmentId, $data);
return response()->json([
'success' => true,
'message' => '점검항목이 추가되었습니다.',
'data' => $template,
], 201);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function updateTemplate(Request $request, int $templateId): JsonResponse
{
try {
$template = $this->inspectionService->updateTemplate($templateId, $request->all());
return response()->json([
'success' => true,
'message' => '점검항목이 수정되었습니다.',
'data' => $template,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function copyTemplates(Request $request, int $equipmentId): JsonResponse
{
$request->validate([
'source_cycle' => 'required|string',
'target_cycles' => 'required|array|min:1',
'target_cycles.*' => 'required|string',
]);
try {
$result = $this->inspectionService->copyTemplatesToCycles(
$equipmentId,
$request->input('source_cycle'),
$request->input('target_cycles')
);
return response()->json([
'success' => true,
'message' => "{$result['copied']}개 항목이 복사되었습니다.".($result['skipped'] > 0 ? " (중복 {$result['skipped']}개 건너뜀)" : ''),
'data' => $result,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function deleteTemplate(int $templateId): JsonResponse
{
try {
$this->inspectionService->deleteTemplate($templateId);
return response()->json([
'success' => true,
'message' => '점검항목이 삭제되었습니다.',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEquipmentRepairRequest;
use App\Services\EquipmentRepairService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentRepairController extends Controller
{
public function __construct(
private EquipmentRepairService $repairService
) {}
public function index(Request $request)
{
$repairs = $this->repairService->getRepairs(
$request->all(),
$request->input('per_page', 20)
);
if ($request->header('HX-Request')) {
return view('equipment.partials.repair-table', compact('repairs'));
}
return response()->json([
'success' => true,
'data' => $repairs->items(),
'meta' => [
'current_page' => $repairs->currentPage(),
'total' => $repairs->total(),
'per_page' => $repairs->perPage(),
'last_page' => $repairs->lastPage(),
],
]);
}
public function store(StoreEquipmentRepairRequest $request): JsonResponse
{
try {
$repair = $this->repairService->createRepair($request->validated());
return response()->json([
'success' => true,
'message' => '수리이력이 등록되었습니다.',
'data' => $repair,
], 201);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function update(Request $request, int $id): JsonResponse
{
try {
$repair = $this->repairService->updateRepair($id, $request->all());
return response()->json([
'success' => true,
'message' => '수리이력이 수정되었습니다.',
'data' => $repair,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function destroy(int $id): JsonResponse
{
try {
$this->repairService->deleteRepair($id);
return response()->json([
'success' => true,
'message' => '수리이력이 삭제되었습니다.',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
}

View File

@@ -0,0 +1,380 @@
<?php
namespace App\Http\Controllers\Api\Admin\HR;
use App\Http\Controllers\Controller;
use App\Models\HR\Attendance;
use App\Services\HR\AttendanceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AttendanceController extends Controller
{
public function __construct(
private AttendanceService $attendanceService
) {}
/**
* 근태 목록 조회 (HTMX → HTML / 일반 → JSON)
*/
public function index(Request $request): JsonResponse|Response
{
$attendances = $this->attendanceService->getAttendances(
$request->all(),
$request->integer('per_page', 20)
);
if ($request->header('HX-Request')) {
$viewName = $request->input('view') === 'manage'
? 'hr.attendances.partials.table-manage'
: 'hr.attendances.partials.table';
return response(view($viewName, compact('attendances')));
}
return response()->json([
'success' => true,
'data' => $attendances->items(),
'meta' => [
'current_page' => $attendances->currentPage(),
'last_page' => $attendances->lastPage(),
'per_page' => $attendances->perPage(),
'total' => $attendances->total(),
],
]);
}
/**
* 월간 통계 (HTMX → HTML / 일반 → JSON)
*/
public function stats(Request $request): JsonResponse|Response
{
$stats = $this->attendanceService->getMonthlyStats(
$request->integer('year') ?: null,
$request->integer('month') ?: null
);
if ($request->header('HX-Request')) {
return response(view('hr.attendances.partials.stats', compact('stats')));
}
return response()->json([
'success' => true,
'data' => $stats,
]);
}
/**
* 월간 캘린더 (HTMX → HTML)
*/
public function calendar(Request $request): JsonResponse|Response
{
$year = $request->integer('year') ?: now()->year;
$month = $request->integer('month') ?: now()->month;
$userId = $request->integer('user_id') ?: null;
$attendances = $this->attendanceService->getMonthlyCalendarData($year, $month, $userId);
$calendarData = $attendances->groupBy(fn ($att) => $att->base_date->format('Y-m-d'));
$employees = $this->attendanceService->getActiveEmployees();
if ($request->header('HX-Request')) {
return response(view('hr.attendances.partials.calendar', compact(
'year', 'month', 'calendarData', 'employees'
))->with('selectedUserId', $userId));
}
return response()->json([
'success' => true,
'data' => $calendarData,
]);
}
/**
* 사원별 월간 요약 (HTMX → HTML)
*/
public function summary(Request $request): JsonResponse|Response
{
$year = $request->integer('year') ?: now()->year;
$month = $request->integer('month') ?: now()->month;
$summary = $this->attendanceService->getEmployeeMonthlySummary($year, $month);
if ($request->header('HX-Request')) {
return response(view('hr.attendances.partials.summary', compact('summary', 'year', 'month')));
}
return response()->json([
'success' => true,
'data' => $summary,
]);
}
/**
* 초과근무 알림 (HTMX → HTML)
*/
public function overtimeAlerts(Request $request): JsonResponse|Response
{
$alerts = $this->attendanceService->getOvertimeAlerts();
if ($request->header('HX-Request')) {
return response(view('hr.attendances.partials.overtime-alerts', compact('alerts')));
}
return response()->json([
'success' => true,
'data' => $alerts,
]);
}
/**
* 잔여 연차 조회
*/
public function leaveBalance(Request $request, int $userId): JsonResponse
{
$balance = $this->attendanceService->getLeaveBalance($userId);
return response()->json([
'success' => true,
'data' => [
'total' => $balance?->total ?? 0,
'used' => $balance?->used ?? 0,
'remaining' => $balance?->remaining ?? 0,
],
]);
}
/**
* 엑셀(CSV) 내보내기
*/
public function export(Request $request): StreamedResponse
{
$attendances = $this->attendanceService->getExportData($request->all());
$tenantId = session('selected_tenant_id');
$filename = '근태현황_'.now()->format('Ymd').'.csv';
return response()->streamDownload(function () use ($attendances) {
$file = fopen('php://output', 'w');
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
fputcsv($file, ['날짜', '사원명', '부서', '상태', '출근', '퇴근', '비고']);
foreach ($attendances as $att) {
$profile = $att->user?->tenantProfiles?->first();
$displayName = $profile?->display_name ?? $att->user?->name ?? '-';
$department = $profile?->department?->name ?? '-';
$statusLabel = Attendance::STATUS_MAP[$att->status] ?? $att->status;
$checkIn = $att->check_in ? substr($att->check_in, 0, 5) : '';
$checkOut = $att->check_out ? substr($att->check_out, 0, 5) : '';
fputcsv($file, [
$att->base_date->format('Y-m-d'),
$displayName,
$department,
$statusLabel,
$checkIn,
$checkOut,
$att->remarks ?? '',
]);
}
fclose($file);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* 일괄 삭제
*/
public function bulkDestroy(Request $request): JsonResponse|Response
{
$validated = $request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer',
]);
try {
$count = $this->attendanceService->bulkDelete($validated['ids']);
if ($request->header('HX-Request')) {
$attendances = $this->attendanceService->getAttendances(
$request->except('ids'),
$request->integer('per_page', 20)
);
return response(view('hr.attendances.partials.table', compact('attendances')));
}
return response()->json([
'success' => true,
'message' => "{$count}건의 근태가 삭제되었습니다.",
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '일괄 삭제 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 근태 등록
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'user_id' => 'required|integer|exists:users,id',
'base_date' => 'required|date',
'status' => 'required|string|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote',
'check_in' => 'nullable|date_format:H:i',
'check_out' => 'nullable|date_format:H:i',
'remarks' => 'nullable|string|max:500',
]);
try {
$attendance = $this->attendanceService->storeAttendance($validated);
return response()->json([
'success' => true,
'message' => '근태가 등록되었습니다.',
'data' => $attendance,
], 201);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '근태 등록 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 일괄 등록
*/
public function bulkStore(Request $request): JsonResponse
{
$validated = $request->validate([
'user_ids' => 'required|array|min:1',
'user_ids.*' => 'integer|exists:users,id',
'base_date' => 'required|date',
'status' => 'required|string|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote',
'check_in' => 'nullable|date_format:H:i',
'check_out' => 'nullable|date_format:H:i',
'remarks' => 'nullable|string|max:500',
]);
try {
$result = $this->attendanceService->bulkStore($validated);
return response()->json([
'success' => true,
'message' => "신규 {$result['created']}건, 수정 {$result['updated']}건 처리되었습니다.",
'data' => $result,
], 201);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '일괄 등록 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 근태 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'status' => 'sometimes|required|string|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote',
'check_in' => 'nullable|date_format:H:i',
'check_out' => 'nullable|date_format:H:i',
'remarks' => 'nullable|string|max:500',
]);
try {
$attendance = $this->attendanceService->updateAttendance($id, $validated);
if (! $attendance) {
return response()->json([
'success' => false,
'message' => '근태 정보를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '근태가 수정되었습니다.',
'data' => $attendance,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '근태 수정 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 근태 삭제
*/
public function destroy(Request $request, int $id): JsonResponse|Response
{
try {
$force = $request->boolean('force');
if ($force) {
if (! auth()->user()->isSuperAdmin()) {
return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403);
}
$result = $this->attendanceService->forceDeleteAttendance($id);
$message = '근태가 영구 삭제되었습니다.';
} else {
$result = $this->attendanceService->deleteAttendance($id);
$message = '근태가 삭제되었습니다.';
}
if (! $result) {
return response()->json([
'success' => false,
'message' => '근태 정보를 찾을 수 없습니다.',
], 404);
}
if ($request->header('HX-Request')) {
$attendances = $this->attendanceService->getAttendances(
$request->all(),
$request->integer('per_page', 20)
);
return response(view('hr.attendances.partials.table', compact('attendances')));
}
return response()->json([
'success' => true,
'message' => $message,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '근태 삭제 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@@ -0,0 +1,389 @@
<?php
namespace App\Http\Controllers\Api\Admin\HR;
use App\Http\Controllers\Controller;
use App\Models\Boards\File;
use App\Services\GoogleCloudStorageService;
use App\Services\HR\BusinessIncomeEarnerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class BusinessIncomeEarnerController extends Controller
{
public function __construct(
private BusinessIncomeEarnerService $service
) {}
/**
* 사업소득자 목록 조회 (HTMX → HTML / 일반 → JSON)
*/
public function index(Request $request): JsonResponse|Response
{
$earners = $this->service->getBusinessIncomeEarners(
$request->all(),
$request->integer('per_page', 20)
);
if ($request->header('HX-Request')) {
return response(view('hr.business-income-earners.partials.table', compact('earners')));
}
return response()->json([
'success' => true,
'data' => $earners->items(),
'meta' => [
'current_page' => $earners->currentPage(),
'last_page' => $earners->lastPage(),
'per_page' => $earners->perPage(),
'total' => $earners->total(),
],
]);
}
/**
* 기존 사용자 검색 (사업소득자 미등록, 테넌트 소속)
*/
public function searchUsers(Request $request): JsonResponse
{
$users = $this->service->searchTenantUsers($request->get('q') ?? '');
return response()->json([
'success' => true,
'data' => $users,
]);
}
/**
* 사업소득자 통계
*/
public function stats(): JsonResponse
{
$stats = $this->service->getStats();
return response()->json([
'success' => true,
'data' => $stats,
]);
}
/**
* 사업소득자 등록
*/
public function store(Request $request): JsonResponse
{
$rules = [
'existing_user_id' => 'nullable|integer|exists:users,id',
'name' => 'required|string|max:50',
'email' => 'nullable|email|max:100',
'phone' => 'nullable|string|max:20',
'department_id' => 'nullable|integer|exists:departments,id',
'position_key' => 'nullable|string|max:50',
'job_title_key' => 'nullable|string|max:50',
'work_location_key' => 'nullable|string|max:50',
'employment_type_key' => 'nullable|string|max:50',
'employee_status' => 'nullable|string|in:active,leave,resigned',
'manager_user_id' => 'nullable|integer|exists:users,id',
'display_name' => 'nullable|string|max:50',
'hire_date' => 'nullable|date',
'resign_date' => 'nullable|date',
'address' => 'nullable|string|max:200',
'emergency_contact' => 'nullable|string|max:100',
'resident_number' => 'nullable|string|max:14',
'bank_account.bank_code' => 'nullable|string|max:20',
'bank_account.bank_name' => 'nullable|string|max:50',
'bank_account.account_holder' => 'nullable|string|max:50',
'bank_account.account_number' => 'nullable|string|max:30',
'dependents' => 'nullable|array',
'dependents.*.name' => 'required_with:dependents|string|max:50',
'dependents.*.nationality' => 'nullable|string|in:korean,foreigner',
'dependents.*.resident_number' => 'nullable|string|max:14',
'dependents.*.relationship' => 'nullable|string|max:20',
'dependents.*.is_disabled' => 'nullable|boolean',
'dependents.*.is_dependent' => 'nullable|boolean',
// 사업장등록정보
'business_registration_number' => 'nullable|string|max:12',
'business_name' => 'nullable|string|max:100',
'business_representative' => 'nullable|string|max:50',
'business_type' => 'nullable|string|max:50',
'business_category' => 'nullable|string|max:50',
'business_address' => 'nullable|string|max:200',
];
if (! $request->filled('existing_user_id')) {
$rules['email'] = 'nullable|email|max:100|unique:users,email';
}
$validated = $request->validate($rules);
try {
$earner = $this->service->create($validated);
return response()->json([
'success' => true,
'message' => '사업소득자가 등록되었습니다.',
'data' => $earner,
], 201);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '사업소득자 등록 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 사업소득자 상세 조회
*/
public function show(int $id): JsonResponse
{
$earner = $this->service->getById($id);
if (! $earner) {
return response()->json([
'success' => false,
'message' => '사업소득자 정보를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $earner,
]);
}
/**
* 사업소득자 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|required|string|max:50',
'email' => 'nullable|email|max:100',
'phone' => 'nullable|string|max:20',
'department_id' => 'nullable|integer|exists:departments,id',
'position_key' => 'nullable|string|max:50',
'job_title_key' => 'nullable|string|max:50',
'work_location_key' => 'nullable|string|max:50',
'employment_type_key' => 'nullable|string|max:50',
'employee_status' => 'nullable|string|in:active,leave,resigned',
'manager_user_id' => 'nullable|integer|exists:users,id',
'display_name' => 'nullable|string|max:50',
'hire_date' => 'nullable|date',
'resign_date' => 'nullable|date',
'address' => 'nullable|string|max:200',
'emergency_contact' => 'nullable|string|max:100',
'resident_number' => 'nullable|string|max:14',
'bank_account.bank_code' => 'nullable|string|max:20',
'bank_account.bank_name' => 'nullable|string|max:50',
'bank_account.account_holder' => 'nullable|string|max:50',
'bank_account.account_number' => 'nullable|string|max:30',
'dependents' => 'nullable|array',
'dependents.*.name' => 'required_with:dependents|string|max:50',
'dependents.*.nationality' => 'nullable|string|in:korean,foreigner',
'dependents.*.resident_number' => 'nullable|string|max:14',
'dependents.*.relationship' => 'nullable|string|max:20',
'dependents.*.is_disabled' => 'nullable|boolean',
'dependents.*.is_dependent' => 'nullable|boolean',
// 사업장등록정보
'business_registration_number' => 'nullable|string|max:12',
'business_name' => 'nullable|string|max:100',
'business_representative' => 'nullable|string|max:50',
'business_type' => 'nullable|string|max:50',
'business_category' => 'nullable|string|max:50',
'business_address' => 'nullable|string|max:200',
]);
if ($request->has('dependents_submitted') && ! array_key_exists('dependents', $validated)) {
$validated['dependents'] = [];
}
try {
$earner = $this->service->update($id, $validated);
if (! $earner) {
return response()->json([
'success' => false,
'message' => '사업소득자 정보를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '사업소득자 정보가 수정되었습니다.',
'data' => $earner,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '사업소득자 수정 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 사업소득자 삭제 (퇴직 처리)
*/
public function destroy(Request $request, int $id): JsonResponse|Response
{
try {
$result = $this->service->delete($id);
if (! $result) {
return response()->json([
'success' => false,
'message' => '사업소득자 정보를 찾을 수 없습니다.',
], 404);
}
if ($request->header('HX-Request')) {
$earners = $this->service->getBusinessIncomeEarners($request->all(), $request->integer('per_page', 20));
return response(view('hr.business-income-earners.partials.table', compact('earners')));
}
return response()->json([
'success' => true,
'message' => '퇴직 처리되었습니다.',
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '퇴직 처리 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 첨부파일 업로드
*/
public function uploadFile(Request $request, int $id, GoogleCloudStorageService $gcs): JsonResponse
{
$earner = $this->service->getById($id);
if (! $earner) {
return response()->json(['success' => false, 'message' => '사업소득자 정보를 찾을 수 없습니다.'], 404);
}
$request->validate([
'files' => 'required|array|max:10',
'files.*' => 'file|max:20480',
]);
$tenantId = session('selected_tenant_id');
$uploaded = [];
foreach ($request->file('files') as $file) {
$storedName = Str::random(40).'.'.$file->getClientOriginalExtension();
$storagePath = "business-income-earners/{$tenantId}/{$earner->id}/{$storedName}";
Storage::disk('tenant')->put($storagePath, file_get_contents($file));
$gcsUri = null;
$gcsObjectName = null;
if ($gcs->isAvailable()) {
$gcsObjectName = $storagePath;
$gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName);
}
$fileRecord = File::create([
'tenant_id' => $tenantId,
'document_id' => $earner->id,
'document_type' => 'business_income_earner_profile',
'original_name' => $file->getClientOriginalName(),
'stored_name' => $storedName,
'file_path' => $storagePath,
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
'file_type' => strtolower($file->getClientOriginalExtension()),
'gcs_object_name' => $gcsObjectName,
'gcs_uri' => $gcsUri,
'uploaded_by' => auth()->id(),
'created_by' => auth()->id(),
]);
$uploaded[] = $fileRecord;
}
return response()->json([
'success' => true,
'message' => count($uploaded).'개 파일이 업로드되었습니다.',
'data' => $uploaded,
], 201);
}
/**
* 첨부파일 삭제
*/
public function deleteFile(int $id, int $fileId, GoogleCloudStorageService $gcs): JsonResponse
{
$tenantId = session('selected_tenant_id');
$file = File::where('id', $fileId)
->where('document_id', $id)
->where('document_type', 'business_income_earner_profile')
->where('tenant_id', $tenantId)
->first();
if (! $file) {
return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404);
}
if ($gcs->isAvailable() && $file->gcs_object_name) {
$gcs->delete($file->gcs_object_name);
}
if ($file->file_path && Storage::disk('tenant')->exists($file->file_path)) {
Storage::disk('tenant')->delete($file->file_path);
}
$file->deleted_by = auth()->id();
$file->save();
$file->delete();
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
}
/**
* 첨부파일 다운로드
*/
public function downloadFile(int $id, int $fileId, GoogleCloudStorageService $gcs)
{
$tenantId = session('selected_tenant_id');
$file = File::where('id', $fileId)
->where('document_id', $id)
->where('document_type', 'business_income_earner_profile')
->where('tenant_id', $tenantId)
->first();
if (! $file) {
abort(404, '파일을 찾을 수 없습니다.');
}
if ($gcs->isAvailable() && $file->gcs_object_name) {
$signedUrl = $gcs->getSignedUrl($file->gcs_object_name, 60);
if ($signedUrl) {
return redirect($signedUrl);
}
}
$disk = Storage::disk('tenant');
if ($file->file_path && $disk->exists($file->file_path)) {
return $disk->download($file->file_path, $file->original_name);
}
abort(404, '파일이 서버에 존재하지 않습니다.');
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace App\Http\Controllers\Api\Admin\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\BusinessIncomePaymentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Color;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BusinessIncomePaymentController extends Controller
{
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
public function __construct(
private BusinessIncomePaymentService $service
) {}
private function checkPayrollAccess(): ?JsonResponse
{
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
return response()->json([
'success' => false,
'message' => '급여관리는 관계자만 볼 수 있습니다.',
], 403);
}
return null;
}
/**
* 사업소득 지급 목록 (HTMX → 스프레드시트 파셜)
*/
public function index(Request $request): JsonResponse|Response
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$year = $request->integer('year') ?: now()->year;
$month = $request->integer('month') ?: now()->month;
$earners = $this->service->getActiveEarners();
$payments = $this->service->getPayments($year, $month);
$stats = $this->service->getMonthlyStats($year, $month);
$earnersForJs = $earners->map(fn ($e) => [
'user_id' => $e->user_id,
'business_name' => $e->business_name ?? ($e->user?->name ?? ''),
'user_name' => $e->user?->name ?? '',
'business_reg_number' => $e->business_registration_number ?? '',
])->values();
if ($request->header('HX-Request')) {
return response(
view('hr.business-income-payments.partials.stats', compact('stats')).
'<!-- SPLIT -->'.
view('hr.business-income-payments.partials.spreadsheet', compact('payments', 'earnersForJs', 'year', 'month'))
);
}
return response()->json([
'success' => true,
'data' => $payments,
'stats' => $stats,
]);
}
/**
* 일괄 저장
*/
public function bulkSave(Request $request): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$validated = $request->validate([
'year' => 'required|integer|min:2020|max:2100',
'month' => 'required|integer|min:1|max:12',
'items' => 'present|array',
'items.*.payment_id' => 'nullable|integer',
'items.*.user_id' => 'nullable|integer',
'items.*.display_name' => 'required|string|max:100',
'items.*.business_reg_number' => 'nullable|string|max:20',
'items.*.gross_amount' => 'required|numeric|min:0',
'items.*.service_content' => 'nullable|string|max:200',
'items.*.payment_date' => 'nullable|date',
'items.*.note' => 'nullable|string|max:500',
]);
$result = $this->service->bulkSave(
$validated['year'],
$validated['month'],
$validated['items']
);
return response()->json([
'success' => true,
'message' => "저장 {$result['saved']}건, 삭제 {$result['deleted']}건, 건너뜀 {$result['skipped']}",
'data' => $result,
]);
}
/**
* XLSX 내보내기 (스타일링 포함)
*/
public function export(Request $request): StreamedResponse|JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$year = $request->integer('year') ?: now()->year;
$month = $request->integer('month') ?: now()->month;
$payments = $this->service->getExportData($year, $month);
$filename = "사업소득자임금대장_{$year}{$month}월_".now()->format('Ymd').'.xlsx';
$spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('사업소득자 임금대장');
$lastCol = 'K';
$headers = ['구분', '상호/성명', "사업자등록번호\n/주민등록번호", '용역내용', '지급총액', "소득세\n(3%)", "지방소득세\n(0.3%)", '공제합계', '실지급액', '지급일자', '비고'];
// ── Row 1: 제목 ──
$sheet->mergeCells("A1:{$lastCol}1");
$sheet->setCellValue('A1', "< {$year}년도 {$month}월 사업소득자 임금대장 >");
$sheet->getStyle('A1')->applyFromArray([
'font' => ['bold' => true, 'size' => 14],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(30);
// ── Row 2: 헤더 ──
foreach ($headers as $colIdx => $header) {
$cell = chr(65 + $colIdx).'2';
$sheet->setCellValue($cell, $header);
}
$sheet->getStyle("A2:{$lastCol}2")->applyFromArray([
'font' => ['bold' => true, 'size' => 10, 'color' => ['argb' => 'FFFFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF1F3864']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]],
]);
$sheet->getRowDimension(2)->setRowHeight(36);
// ── Row 3~: 데이터 ──
$dataStartRow = 3;
$row = $dataStartRow;
$moneyColumns = ['E', 'F', 'G', 'H', 'I'];
foreach ($payments as $idx => $payment) {
$name = $payment->display_name ?: ($payment->user?->name ?? '-');
$regNumber = $payment->business_reg_number ?? '';
$sheet->setCellValue("A{$row}", $idx + 1);
$sheet->setCellValue("B{$row}", $name);
$sheet->setCellValueExplicit("C{$row}", $regNumber, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
$sheet->setCellValue("D{$row}", $payment->service_content ?? '');
$sheet->setCellValue("E{$row}", (int) $payment->gross_amount);
$sheet->setCellValue("F{$row}", (int) $payment->income_tax);
$sheet->setCellValue("G{$row}", (int) $payment->local_income_tax);
$sheet->setCellValue("H{$row}", (int) $payment->total_deductions);
$sheet->setCellValue("I{$row}", (int) $payment->net_amount);
$sheet->setCellValue("J{$row}", $payment->payment_date?->format('Y-m-d') ?? '');
$sheet->setCellValue("K{$row}", $payment->note ?? '');
// 지급일자 빨간색
if ($payment->payment_date) {
$sheet->getStyle("J{$row}")->getFont()->setColor(new Color('FF0000'));
}
$row++;
}
// 빈 행 채움 (최소 10행)
$minEndRow = $dataStartRow + 9;
while ($row <= $minEndRow) {
$row++;
}
$lastDataRow = $row - 1;
// ── 데이터 영역 스타일 ──
$dataRange = "A{$dataStartRow}:{$lastCol}{$lastDataRow}";
$sheet->getStyle($dataRange)->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10],
]);
// 가운데 정렬: 구분, 용역내용, 지급일자, 비고
foreach (['A', 'D', 'J', 'K'] as $col) {
$sheet->getStyle("{$col}{$dataStartRow}:{$col}{$lastDataRow}")
->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
}
// 금액 서식: #,##0 + 오른쪽 정렬
foreach ($moneyColumns as $col) {
$range = "{$col}{$dataStartRow}:{$col}{$lastDataRow}";
$sheet->getStyle($range)->getNumberFormat()->setFormatCode('#,##0');
$sheet->getStyle($range)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
}
// ── 열 너비 ──
$widths = ['A' => 8, 'B' => 14, 'C' => 22, 'D' => 14, 'E' => 14, 'F' => 14, 'G' => 14, 'H' => 14, 'I' => 14, 'J' => 14, 'K' => 14];
foreach ($widths as $col => $width) {
$sheet->getColumnDimension($col)->setWidth($width);
}
// ── 응답 반환 ──
return response()->streamDownload(function () use ($spreadsheet) {
$writer = new Xlsx($spreadsheet);
$writer->save('php://output');
$spreadsheet->disconnectWorksheets();
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'max-age=0',
]);
}
}

View File

@@ -0,0 +1,597 @@
<?php
namespace App\Http\Controllers\Api\Admin\HR;
use App\Http\Controllers\Controller;
use App\Models\Boards\File;
use App\Services\GoogleCloudStorageService;
use App\Services\HR\EmployeeService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EmployeeController extends Controller
{
public function __construct(
private EmployeeService $employeeService
) {}
/**
* 사원 목록 조회 (HTMX → HTML / 일반 → JSON)
*/
public function index(Request $request): JsonResponse|Response
{
$employees = $this->employeeService->getEmployees(
$request->all(),
$request->integer('per_page', 20)
);
if ($request->header('HX-Request')) {
return response(view('hr.employees.partials.table', compact('employees')));
}
return response()->json([
'success' => true,
'data' => $employees->items(),
'meta' => [
'current_page' => $employees->currentPage(),
'last_page' => $employees->lastPage(),
'per_page' => $employees->perPage(),
'total' => $employees->total(),
],
]);
}
/**
* 기존 사용자 검색 (사원 미등록, 테넌트 소속)
*/
public function searchUsers(Request $request): JsonResponse
{
$users = $this->employeeService->searchTenantUsers($request->get('q') ?? '');
return response()->json([
'success' => true,
'data' => $users,
]);
}
/**
* 사원 통계
*/
public function stats(): JsonResponse
{
$stats = $this->employeeService->getStats();
return response()->json([
'success' => true,
'data' => $stats,
]);
}
/**
* 사원 등록
*/
public function store(Request $request): JsonResponse
{
$rules = [
'existing_user_id' => 'nullable|integer|exists:users,id',
'name' => 'required|string|max:50',
'email' => 'nullable|email|max:100',
'phone' => 'nullable|string|max:20',
'department_id' => 'nullable|integer|exists:departments,id',
'position_key' => 'nullable|string|max:50',
'job_title_key' => 'nullable|string|max:50',
'work_location_key' => 'nullable|string|max:50',
'employment_type_key' => 'nullable|string|max:50',
'employee_status' => 'nullable|string|in:active,leave,resigned',
'manager_user_id' => 'nullable|integer|exists:users,id',
'display_name' => 'nullable|string|max:50',
'hire_date' => 'nullable|date',
'resign_date' => 'nullable|date',
'address' => 'nullable|string|max:200',
'emergency_contact' => 'nullable|string|max:100',
'resident_number' => 'nullable|string|max:14',
'bank_account.bank_code' => 'nullable|string|max:20',
'bank_account.bank_name' => 'nullable|string|max:50',
'bank_account.account_holder' => 'nullable|string|max:50',
'bank_account.account_number' => 'nullable|string|max:30',
'dependents' => 'nullable|array',
'dependents.*.name' => 'required_with:dependents|string|max:50',
'dependents.*.nationality' => 'nullable|string|in:korean,foreigner',
'dependents.*.resident_number' => 'nullable|string|max:14',
'dependents.*.relationship' => 'nullable|string|max:20',
'dependents.*.is_disabled' => 'nullable|boolean',
'dependents.*.is_dependent' => 'nullable|boolean',
];
// 신규 사용자일 때만 이메일 unique 검증
if (! $request->filled('existing_user_id')) {
$rules['email'] = 'nullable|email|max:100|unique:users,email';
}
$validated = $request->validate($rules);
try {
$employee = $this->employeeService->createEmployee($validated);
return response()->json([
'success' => true,
'message' => '사원이 등록되었습니다.',
'data' => $employee,
], 201);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '사원 등록 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 사원 상세 조회
*/
public function show(int $id): JsonResponse
{
$employee = $this->employeeService->getEmployeeById($id);
if (! $employee) {
return response()->json([
'success' => false,
'message' => '사원 정보를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $employee,
]);
}
/**
* 사원 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|required|string|max:50',
'email' => 'nullable|email|max:100',
'phone' => 'nullable|string|max:20',
'department_id' => 'nullable|integer|exists:departments,id',
'position_key' => 'nullable|string|max:50',
'job_title_key' => 'nullable|string|max:50',
'work_location_key' => 'nullable|string|max:50',
'employment_type_key' => 'nullable|string|max:50',
'employee_status' => 'nullable|string|in:active,leave,resigned',
'manager_user_id' => 'nullable|integer|exists:users,id',
'display_name' => 'nullable|string|max:50',
'hire_date' => 'nullable|date',
'resign_date' => 'nullable|date',
'address' => 'nullable|string|max:200',
'emergency_contact' => 'nullable|string|max:100',
'resident_number' => 'nullable|string|max:14',
'bank_account.bank_code' => 'nullable|string|max:20',
'bank_account.bank_name' => 'nullable|string|max:50',
'bank_account.account_holder' => 'nullable|string|max:50',
'bank_account.account_number' => 'nullable|string|max:30',
'dependents' => 'nullable|array',
'dependents.*.name' => 'required_with:dependents|string|max:50',
'dependents.*.nationality' => 'nullable|string|in:korean,foreigner',
'dependents.*.resident_number' => 'nullable|string|max:14',
'dependents.*.relationship' => 'nullable|string|max:20',
'dependents.*.is_disabled' => 'nullable|boolean',
'dependents.*.is_dependent' => 'nullable|boolean',
]);
// 부양가족 섹션이 포함된 폼인데 dependents 데이터가 없으면 → 전체 삭제
if ($request->has('dependents_submitted') && ! array_key_exists('dependents', $validated)) {
$validated['dependents'] = [];
}
try {
$employee = $this->employeeService->updateEmployee($id, $validated);
if (! $employee) {
return response()->json([
'success' => false,
'message' => '사원 정보를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '사원 정보가 수정되었습니다.',
'data' => $employee,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '사원 수정 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 사원 삭제 (퇴직 처리)
*/
public function destroy(Request $request, int $id): JsonResponse|Response
{
try {
$result = $this->employeeService->deleteEmployee($id);
if (! $result) {
if ($request->header('HX-Request')) {
return response()->json([
'success' => false,
'message' => '사원 정보를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => false,
'message' => '사원 정보를 찾을 수 없습니다.',
], 404);
}
if ($request->header('HX-Request')) {
$employees = $this->employeeService->getEmployees($request->all(), $request->integer('per_page', 20));
return response(view('hr.employees.partials.table', compact('employees')));
}
return response()->json([
'success' => true,
'message' => '퇴직 처리되었습니다.',
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '퇴직 처리 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 사원 영구삭제 (퇴직자만, 슈퍼관리자 전용)
*/
public function forceDestroy(Request $request, int $id): JsonResponse|Response
{
if (! auth()->user()?->is_super_admin) {
return response()->json([
'success' => false,
'message' => '슈퍼관리자만 영구삭제할 수 있습니다.',
], 403);
}
try {
$result = $this->employeeService->forceDeleteEmployee($id);
if (! $result['success']) {
return response()->json($result, 422);
}
if ($request->header('HX-Request')) {
$employees = $this->employeeService->getEmployees($request->all(), $request->integer('per_page', 20));
return response(view('hr.employees.partials.table', compact('employees')));
}
return response()->json($result);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '영구삭제 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 사원 제외/복원 토글
*/
public function toggleExclude(Request $request, int $id): JsonResponse|Response
{
$employee = $this->employeeService->toggleExclude($id);
if (! $employee) {
return response()->json([
'success' => false,
'message' => '사원 정보를 찾을 수 없습니다.',
], 404);
}
$isExcluded = $employee->getJsonExtraValue('is_excluded', false);
if ($request->header('HX-Request')) {
$employees = $this->employeeService->getEmployees(
$request->all(),
$request->integer('per_page', 20)
);
return response(view('hr.employees.partials.table', compact('employees')));
}
return response()->json([
'success' => true,
'message' => $isExcluded ? '사원이 목록에서 제외되었습니다.' : '사원이 목록에 복원되었습니다.',
'is_excluded' => $isExcluded,
]);
}
/**
* 사원 첨부파일 업로드 (로컬 + GCS 듀얼 저장)
*/
public function uploadFile(Request $request, int $id, GoogleCloudStorageService $gcs): JsonResponse
{
$employee = $this->employeeService->getEmployeeById($id);
if (! $employee) {
return response()->json(['success' => false, 'message' => '사원 정보를 찾을 수 없습니다.'], 404);
}
$request->validate([
'files' => 'required|array|max:10',
'files.*' => 'file|max:20480',
]);
$tenantId = session('selected_tenant_id');
$uploaded = [];
foreach ($request->file('files') as $file) {
$storedName = Str::random(40).'.'.$file->getClientOriginalExtension();
$storagePath = "employees/{$tenantId}/{$employee->id}/{$storedName}";
// 로컬 저장
Storage::disk('tenant')->put($storagePath, file_get_contents($file));
// GCS 업로드
$gcsUri = null;
$gcsObjectName = null;
if ($gcs->isAvailable()) {
$gcsObjectName = $storagePath;
$gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName);
}
$fileRecord = File::create([
'tenant_id' => $tenantId,
'document_id' => $employee->id,
'document_type' => 'employee_profile',
'original_name' => $file->getClientOriginalName(),
'stored_name' => $storedName,
'file_path' => $storagePath,
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
'file_type' => strtolower($file->getClientOriginalExtension()),
'gcs_object_name' => $gcsObjectName,
'gcs_uri' => $gcsUri,
'uploaded_by' => auth()->id(),
'created_by' => auth()->id(),
]);
$uploaded[] = $fileRecord;
}
return response()->json([
'success' => true,
'message' => count($uploaded).'개 파일이 업로드되었습니다.',
'data' => $uploaded,
], 201);
}
/**
* 사원 첨부파일 삭제 (GCS + 로컬 모두 삭제)
*/
public function deleteFile(int $id, int $fileId, GoogleCloudStorageService $gcs): JsonResponse
{
$tenantId = session('selected_tenant_id');
$file = File::where('id', $fileId)
->where('document_id', $id)
->where('document_type', 'employee_profile')
->where('tenant_id', $tenantId)
->first();
if (! $file) {
return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404);
}
// GCS 삭제
if ($gcs->isAvailable() && $file->gcs_object_name) {
$gcs->delete($file->gcs_object_name);
}
// 로컬 삭제
if ($file->file_path && Storage::disk('tenant')->exists($file->file_path)) {
Storage::disk('tenant')->delete($file->file_path);
}
$file->deleted_by = auth()->id();
$file->save();
$file->delete();
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
}
/**
* 사원 첨부파일 다운로드 (GCS Signed URL 우선, 로컬 폴백)
*/
public function downloadFile(int $id, int $fileId, GoogleCloudStorageService $gcs)
{
$tenantId = session('selected_tenant_id');
$file = File::where('id', $fileId)
->where('document_id', $id)
->where('document_type', 'employee_profile')
->where('tenant_id', $tenantId)
->first();
if (! $file) {
abort(404, '파일을 찾을 수 없습니다.');
}
// GCS Signed URL로 리다이렉트
if ($gcs->isAvailable() && $file->gcs_object_name) {
$signedUrl = $gcs->getSignedUrl($file->gcs_object_name, 60);
if ($signedUrl) {
return redirect($signedUrl);
}
}
// 로컬 폴백
$disk = Storage::disk('tenant');
if ($file->file_path && $disk->exists($file->file_path)) {
return $disk->download($file->file_path, $file->original_name);
}
abort(404, '파일이 서버에 존재하지 않습니다.');
}
/**
* 입퇴사자 현황 목록 (HTMX → HTML / 일반 → JSON)
*/
public function tenure(Request $request): JsonResponse|Response
{
$employees = $this->employeeService->getEmployeeTenure(
$request->all(),
$request->integer('per_page', 50)
);
// 근속기간 계산 추가
$employees->getCollection()->each(function ($employee) {
$hireDate = $employee->hire_date;
if ($hireDate) {
$hire = Carbon::parse($hireDate);
$end = $employee->resign_date ? Carbon::parse($employee->resign_date) : today();
$tenureDays = $hire->diffInDays($end);
$diff = $hire->diff($end);
$employee->tenure_days = $tenureDays;
$employee->tenure_label = $this->formatTenureLabel($diff);
} else {
$employee->tenure_days = 0;
$employee->tenure_label = '-';
}
});
if ($request->header('HX-Request')) {
$stats = $this->employeeService->getTenureStats();
return response(view('hr.employee-tenure.partials.table', compact('employees', 'stats')));
}
return response()->json([
'success' => true,
'data' => $employees->items(),
'meta' => [
'current_page' => $employees->currentPage(),
'last_page' => $employees->lastPage(),
'per_page' => $employees->perPage(),
'total' => $employees->total(),
],
]);
}
/**
* 입퇴사자 현황 CSV 내보내기
*/
public function tenureExport(Request $request): StreamedResponse
{
$employees = $this->employeeService->getTenureExportData($request->all());
$filename = '입퇴사자현황_'.now()->format('Ymd').'.csv';
return response()->streamDownload(function () use ($employees) {
$handle = fopen('php://output', 'w');
// BOM for Excel UTF-8
fwrite($handle, "\xEF\xBB\xBF");
// 헤더
fputcsv($handle, ['No.', '사원명', '부서', '직책', '상태', '입사일', '퇴사일', '근속기간', '근속일수']);
$index = 1;
foreach ($employees as $employee) {
$hireDate = $employee->hire_date;
$tenureDays = 0;
$tenureLabel = '-';
if ($hireDate) {
$hire = Carbon::parse($hireDate);
$end = $employee->resign_date ? Carbon::parse($employee->resign_date) : today();
$tenureDays = $hire->diffInDays($end);
$tenureLabel = $this->formatTenureLabel($hire->diff($end));
}
$statusMap = ['active' => '재직', 'leave' => '휴직', 'resigned' => '퇴직'];
fputcsv($handle, [
$index++,
$employee->display_name ?? $employee->user?->name ?? '-',
$employee->department?->name ?? '-',
$employee->position_label ?? '-',
$statusMap[$employee->employee_status] ?? $employee->employee_status,
$employee->hire_date ?? '-',
$employee->resign_date ?? '-',
$tenureLabel,
$tenureDays,
]);
}
fclose($handle);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function formatTenureLabel(\DateInterval $diff): string
{
$parts = [];
if ($diff->y > 0) {
$parts[] = "{$diff->y}";
}
if ($diff->m > 0) {
$parts[] = "{$diff->m}개월";
}
if ($diff->d > 0 || empty($parts)) {
$parts[] = "{$diff->d}";
}
return implode(' ', $parts);
}
/**
* 직급/직책 추가
*/
public function storePosition(Request $request): JsonResponse
{
$validated = $request->validate([
'type' => 'required|string|in:rank,title',
'name' => 'required|string|max:50',
]);
$position = $this->employeeService->createPosition($validated['type'], $validated['name']);
return response()->json([
'success' => true,
'message' => ($validated['type'] === 'rank' ? '직급' : '직책').'이 추가되었습니다.',
'data' => [
'id' => $position->id,
'key' => $position->key,
'name' => $position->name,
],
], 201);
}
}

View File

@@ -0,0 +1,319 @@
<?php
namespace App\Http\Controllers\Api\Admin\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\LeaveService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class LeaveController extends Controller
{
public function __construct(
private LeaveService $leaveService
) {}
/**
* 휴가 목록 (HTMX → HTML / 일반 → JSON)
*/
public function index(Request $request): JsonResponse|Response
{
$leaves = $this->leaveService->getLeaves(
$request->all(),
$request->integer('per_page', 20)
);
if ($request->header('HX-Request')) {
return response(view('hr.leaves.partials.table', compact('leaves')));
}
return response()->json([
'success' => true,
'data' => $leaves->items(),
'meta' => [
'current_page' => $leaves->currentPage(),
'last_page' => $leaves->lastPage(),
'per_page' => $leaves->perPage(),
'total' => $leaves->total(),
],
]);
}
/**
* 휴가 신청 등록
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'user_id' => 'required|integer|exists:users,id',
'leave_type' => 'required|string|in:annual,half_am,half_pm,sick,family,maternity,parental,business_trip,remote,field_work,early_leave,late_reason,absent_reason',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'reason' => 'nullable|string|max:1000',
'approval_line_id' => 'nullable|integer|exists:approval_lines,id',
]);
try {
$leave = $this->leaveService->storeLeave($validated);
return response()->json([
'success' => true,
'message' => '휴가 신청이 등록되었습니다.',
'data' => $leave,
], 201);
} catch (\RuntimeException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '휴가 등록 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 승인
*/
public function approve(Request $request, int $id): JsonResponse
{
try {
$leave = $this->leaveService->approve($id);
if (! $leave) {
return response()->json([
'success' => false,
'message' => '대기 중인 휴가 신청을 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '승인 처리되었습니다.',
'data' => $leave,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '승인 처리 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 반려
*/
public function reject(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'reject_reason' => 'nullable|string|max:1000',
]);
try {
$leave = $this->leaveService->reject($id, $validated['reject_reason'] ?? null);
if (! $leave) {
return response()->json([
'success' => false,
'message' => '대기 중인 휴가 신청을 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '반려 처리되었습니다.',
'data' => $leave,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '반려 처리 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 취소
*/
public function cancel(Request $request, int $id): JsonResponse
{
try {
$leave = $this->leaveService->cancel($id);
if (! $leave) {
return response()->json([
'success' => false,
'message' => '승인된 휴가 신청을 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '취소 처리되었습니다. 연차가 복원되었습니다.',
'data' => $leave,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '취소 처리 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 신청 삭제 (일반: pending만 / 슈퍼관리자: 모든 상태, force 영구삭제)
*/
public function destroy(Request $request, int $id): JsonResponse
{
try {
$force = $request->boolean('force');
$isSuperAdmin = auth()->user()->isSuperAdmin();
if ($force) {
if (! $isSuperAdmin) {
return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403);
}
$leave = $this->leaveService->forceDeleteLeave($id);
$message = '신청이 영구 삭제되었습니다.';
} elseif ($isSuperAdmin) {
$leave = $this->leaveService->deleteLeave($id);
$message = '신청이 삭제되었습니다.';
} else {
$leave = $this->leaveService->deletePendingLeave($id);
$message = '신청이 삭제되었습니다.';
}
if (! $leave) {
return response()->json([
'success' => false,
'message' => '삭제 가능한 신청을 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => $message,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '삭제 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 잔여연차 목록 (HTMX → HTML)
*/
public function balance(Request $request): JsonResponse|Response
{
$year = $request->integer('year', now()->year);
$sort = $request->input('sort', 'hire_date');
$direction = $request->input('direction', 'asc');
$empStatus = $request->input('emp_status');
$balances = $this->leaveService->getBalanceSummary($year, $sort, $direction, $empStatus);
if ($request->header('HX-Request')) {
return response(view('hr.leaves.partials.balance', compact('balances', 'year', 'sort', 'direction', 'empStatus')));
}
return response()->json([
'success' => true,
'data' => $balances,
]);
}
/**
* 개별 사원 잔여연차 (JSON)
*/
public function userBalance(Request $request, int $userId): JsonResponse
{
$year = $request->integer('year', now()->year);
$balance = $this->leaveService->getUserBalance($userId, $year);
return response()->json([
'success' => true,
'data' => $balance ? [
'total_days' => $balance->total_days,
'used_days' => $balance->used_days,
'remaining_days' => $balance->remaining,
] : null,
]);
}
/**
* 사용현황 통계 (HTMX → HTML)
*/
public function stats(Request $request): JsonResponse|Response
{
$year = $request->integer('year', now()->year);
$stats = $this->leaveService->getUsageStats($year);
if ($request->header('HX-Request')) {
return response(view('hr.leaves.partials.stats', compact('stats')));
}
return response()->json([
'success' => true,
'data' => $stats,
]);
}
/**
* CSV 내보내기
*/
public function export(Request $request): StreamedResponse
{
$filters = $request->all();
$leaves = $this->leaveService->getExportData($filters);
$headers = [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="leaves_'.now()->format('Ymd_His').'.csv"',
];
return response()->stream(function () use ($leaves) {
$output = fopen('php://output', 'w');
fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF)); // BOM
fputcsv($output, ['사원', '부서', '유형', '시작일', '종료일', '일수', '사유', '상태', '승인자', '신청일']);
foreach ($leaves as $leave) {
$profile = $leave->user?->tenantProfiles?->first();
fputcsv($output, [
$leave->user?->name ?? '-',
$profile?->department?->name ?? '-',
$leave->type_label,
$leave->start_date->format('Y-m-d'),
$leave->end_date->format('Y-m-d'),
$leave->days,
$leave->reason ?? '-',
$leave->status_label,
$leave->approver?->name ?? '-',
$leave->created_at->format('Y-m-d H:i'),
]);
}
fclose($output);
}, 200, $headers);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Api\Admin\Rd;
use App\Http\Controllers\Controller;
use App\Http\Requests\Rd\StoreAiQuotationRequest;
use App\Services\Rd\AiQuotationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AiQuotationController extends Controller
{
public function __construct(
private readonly AiQuotationService $quotationService
) {}
/**
* 목록 (HTMX partial 또는 JSON)
*/
public function index(Request $request): View|JsonResponse
{
$params = $request->only(['status', 'search', 'per_page']);
$quotations = $this->quotationService->getList($params);
if ($request->header('HX-Request')) {
return view('rd.ai-quotation.partials.table', compact('quotations'));
}
return response()->json([
'success' => true,
'data' => $quotations,
]);
}
/**
* 견적 생성 + AI 분석 실행
*/
public function store(StoreAiQuotationRequest $request): JsonResponse
{
$result = $this->quotationService->createAndAnalyze($request->validated());
if ($result['ok']) {
return response()->json([
'success' => true,
'message' => 'AI 분석이 완료되었습니다.',
'data' => $result['quotation'],
]);
}
return response()->json([
'success' => false,
'message' => 'AI 분석에 실패했습니다.',
'error' => $result['error'],
'data' => $result['quotation'] ?? null,
], 422);
}
/**
* 상세 조회
*/
public function show(int $id): JsonResponse
{
$quotation = $this->quotationService->getById($id);
if (! $quotation) {
return response()->json([
'success' => false,
'message' => 'AI 견적을 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $quotation,
]);
}
/**
* AI 재분석
*/
public function analyze(int $id): JsonResponse
{
$quotation = $this->quotationService->getById($id);
if (! $quotation) {
return response()->json([
'success' => false,
'message' => 'AI 견적을 찾을 수 없습니다.',
], 404);
}
// 제조 모드는 제조용 분석 실행
if ($quotation->isManufacture()) {
$result = $this->quotationService->runManufactureAnalysis($quotation);
} else {
$result = $this->quotationService->runAnalysis($quotation);
}
if ($result['ok']) {
return response()->json([
'success' => true,
'message' => 'AI 재분석이 완료되었습니다.',
'data' => $result['quotation'],
]);
}
return response()->json([
'success' => false,
'message' => 'AI 재분석에 실패했습니다.',
'error' => $result['error'],
], 422);
}
/**
* 견적 편집 저장
*/
public function update(Request $request, int $id): JsonResponse
{
$result = $this->quotationService->updateQuotation($id, $request->all());
if ($result['ok']) {
return response()->json([
'success' => true,
'message' => '견적이 저장되었습니다.',
'data' => $result['quotation'],
]);
}
return response()->json([
'success' => false,
'message' => '견적 저장에 실패했습니다.',
'error' => $result['error'] ?? null,
], 422);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\Admin\Roadmap;
use App\Http\Controllers\Controller;
use App\Http\Requests\Roadmap\StoreMilestoneRequest;
use App\Http\Requests\Roadmap\UpdateMilestoneRequest;
use App\Services\Roadmap\RoadmapMilestoneService;
use Illuminate\Http\JsonResponse;
class RoadmapMilestoneController extends Controller
{
public function __construct(
private readonly RoadmapMilestoneService $milestoneService
) {}
public function byPlan(int $planId): JsonResponse
{
$milestones = $this->milestoneService->getMilestonesByPlan($planId);
return response()->json([
'success' => true,
'data' => $milestones,
]);
}
public function store(StoreMilestoneRequest $request): JsonResponse
{
$milestone = $this->milestoneService->createMilestone($request->validated());
return response()->json([
'success' => true,
'message' => '마일스톤이 추가되었습니다.',
'data' => $milestone,
]);
}
public function update(UpdateMilestoneRequest $request, int $id): JsonResponse
{
$this->milestoneService->updateMilestone($id, $request->validated());
return response()->json([
'success' => true,
'message' => '마일스톤이 수정되었습니다.',
]);
}
public function destroy(int $id): JsonResponse
{
$this->milestoneService->deleteMilestone($id);
return response()->json([
'success' => true,
'message' => '마일스톤이 삭제되었습니다.',
]);
}
public function toggle(int $id): JsonResponse
{
$milestone = $this->milestoneService->toggleStatus($id);
return response()->json([
'success' => true,
'message' => $milestone->status === 'completed' ? '마일스톤이 완료 처리되었습니다.' : '마일스톤이 미완료로 변경되었습니다.',
'data' => $milestone,
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Api\Admin\Roadmap;
use App\Http\Controllers\Controller;
use App\Http\Requests\Roadmap\StorePlanRequest;
use App\Http\Requests\Roadmap\UpdatePlanRequest;
use App\Services\Roadmap\RoadmapPlanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RoadmapPlanController extends Controller
{
public function __construct(
private readonly RoadmapPlanService $planService
) {}
public function index(Request $request): View|JsonResponse
{
$filters = $request->only([
'search', 'status', 'category', 'priority', 'phase',
'trashed', 'sort_by', 'sort_direction',
]);
$plans = $this->planService->getPlans($filters, 15);
if ($request->header('HX-Request')) {
return view('roadmap.plans.partials.table', compact('plans'));
}
return response()->json([
'success' => true,
'data' => $plans,
]);
}
public function stats(): JsonResponse
{
$stats = $this->planService->getStats();
return response()->json([
'success' => true,
'data' => $stats,
]);
}
public function timeline(Request $request): JsonResponse
{
$phase = $request->input('phase');
$timeline = $this->planService->getTimelineData($phase);
return response()->json([
'success' => true,
'data' => $timeline,
]);
}
public function show(int $id): JsonResponse
{
$plan = $this->planService->getPlanById($id, true);
if (! $plan) {
return response()->json([
'success' => false,
'message' => '계획을 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $plan,
]);
}
public function store(StorePlanRequest $request): JsonResponse
{
$plan = $this->planService->createPlan($request->validated());
return response()->json([
'success' => true,
'message' => '계획이 생성되었습니다.',
'data' => $plan,
]);
}
public function update(UpdatePlanRequest $request, int $id): JsonResponse
{
$this->planService->updatePlan($id, $request->validated());
return response()->json([
'success' => true,
'message' => '계획이 수정되었습니다.',
]);
}
public function destroy(int $id): JsonResponse
{
$this->planService->deletePlan($id);
return response()->json([
'success' => true,
'message' => '계획이 삭제되었습니다.',
]);
}
public function restore(int $id): JsonResponse
{
$this->planService->restorePlan($id);
return response()->json([
'success' => true,
'message' => '계획이 복원되었습니다.',
]);
}
public function changeStatus(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'status' => 'required|in:planned,in_progress,completed,delayed,cancelled',
]);
$plan = $this->planService->changeStatus($id, $validated['status']);
return response()->json([
'success' => true,
'message' => '상태가 변경되었습니다.',
'data' => $plan,
]);
}
}

View File

@@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTenantRequest;
use App\Http\Requests\UpdateTenantRequest;
use App\Models\Tenants\TenantSetting;
use App\Services\TenantService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -104,6 +105,22 @@ public function update(UpdateTenantRequest $request, int $id): JsonResponse
{
$this->tenantService->updateTenant($id, $request->validated());
// 인쇄용 회사 표시명 저장 (tenant_settings)
if ($request->has('display_company_name')) {
TenantSetting::withoutGlobalScopes()->updateOrCreate(
[
'tenant_id' => $id,
'setting_group' => 'company',
'setting_key' => 'display_company_name',
],
[
'setting_value' => trim($request->input('display_company_name', '')),
'description' => '문서에 인쇄되는 회사 표시명',
'updated_by' => auth()->id(),
]
);
}
// HTMX 요청 시 성공 메시지와 리다이렉트 헤더 반환
if ($request->header('HX-Request')) {
return response()->json([

View File

@@ -23,18 +23,20 @@ public function search(Request $request): JsonResponse
->where('user_tenants.tenant_id', $tenantId)
->where('user_tenants.is_active', true);
})
->leftJoin('departments', function ($join) {
$join->on('departments.id', '=', DB::raw('(
SELECT du.department_id FROM department_user du
WHERE du.user_id = users.id AND du.is_primary = 1
LIMIT 1
)'));
->leftJoin('tenant_user_profiles as tp', function ($join) use ($tenantId) {
$join->on('tp.user_id', '=', 'users.id')
->where('tp.tenant_id', $tenantId);
})
->leftJoin('departments', 'departments.id', '=', 'tp.department_id')
->whereNull('users.deleted_at')
->where(function ($q) {
$q->whereNull('tp.employee_status')
->orWhere('tp.employee_status', '!=', 'resigned');
})
->when($query, function ($q) use ($query) {
$q->where(function ($sub) use ($query) {
$sub->where('users.name', 'like', "%{$query}%")
->orWhere('users.email', 'like', "%{$query}%");
->orWhere('departments.name', 'like', "%{$query}%");
});
})
->orderBy('users.name')
@@ -42,7 +44,8 @@ public function search(Request $request): JsonResponse
->select([
'users.id',
'users.name',
'departments.name as department_name',
'departments.name as department',
'tp.position_key as position',
])
->get();
@@ -51,4 +54,89 @@ public function search(Request $request): JsonResponse
'data' => $users,
]);
}
/**
* 테넌트 전체 인원 목록 (부서별 그룹핑, 결재선 에디터용)
*/
public function list(): JsonResponse
{
$tenantId = session('selected_tenant_id');
$users = DB::table('users')
->join('user_tenants', function ($join) use ($tenantId) {
$join->on('users.id', '=', 'user_tenants.user_id')
->where('user_tenants.tenant_id', $tenantId)
->where('user_tenants.is_active', true);
})
->leftJoin('tenant_user_profiles as tp', function ($join) use ($tenantId) {
$join->on('tp.user_id', '=', 'users.id')
->where('tp.tenant_id', $tenantId);
})
->leftJoin('departments', 'departments.id', '=', 'tp.department_id')
->leftJoin('positions as pos_rank', function ($join) use ($tenantId) {
$join->on('pos_rank.tenant_id', '=', DB::raw($tenantId))
->where('pos_rank.type', 'rank')
->whereRaw('pos_rank.`key` COLLATE utf8mb4_unicode_ci = tp.position_key COLLATE utf8mb4_unicode_ci');
})
->leftJoin('positions as pos_title', function ($join) use ($tenantId) {
$join->on('pos_title.tenant_id', '=', DB::raw($tenantId))
->where('pos_title.type', 'title')
->whereRaw('pos_title.`key` COLLATE utf8mb4_unicode_ci = tp.job_title_key COLLATE utf8mb4_unicode_ci');
})
->whereNull('users.deleted_at')
->whereNotNull('tp.department_id')
->where(function ($q) {
$q->whereNull('tp.employee_status')
->orWhere('tp.employee_status', '!=', 'resigned');
})
->when($tenantId == 1, function ($q) {
$q->where('departments.name', '!=', '영업팀');
})
->orderBy('departments.name')
->orderByRaw('COALESCE(pos_rank.sort_order, pos_title.sort_order, 9999) ASC')
->orderBy('users.name')
->select([
'users.id',
'users.name',
'tp.department_id',
'departments.name as department_name',
DB::raw('COALESCE(pos_rank.name, tp.position_key, \'\') as position'),
DB::raw('COALESCE(pos_title.name, tp.job_title_key, \'\') as job_title'),
])
->get();
$grouped = $users->groupBy(fn ($u) => $u->department_id ?? 'none');
$data = [];
foreach ($grouped as $deptId => $deptUsers) {
$first = $deptUsers->first();
$data[] = [
'department_id' => $deptId === 'none' ? null : (int) $deptId,
'department_name' => $deptId === 'none' ? '미배정' : $first->department_name,
'users' => $deptUsers->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'position' => $u->position,
'job_title' => $u->job_title,
])->values()->toArray(),
];
}
// 미배정 그룹을 마지막으로
usort($data, function ($a, $b) {
if ($a['department_id'] === null) {
return 1;
}
if ($b['department_id'] === null) {
return -1;
}
return strcmp($a['department_name'], $b['department_name']);
});
return response()->json([
'success' => true,
'data' => $data,
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\SidebarMenuService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MenuFavoriteController extends Controller
{
public function __construct(
private SidebarMenuService $sidebarMenuService
) {}
public function toggle(Request $request): JsonResponse
{
$request->validate([
'menu_id' => 'required|integer|exists:menus,id',
]);
$result = $this->sidebarMenuService->toggleFavorite(
auth()->id(),
$request->integer('menu_id')
);
return response()->json($result);
}
public function reorder(Request $request): JsonResponse
{
$request->validate([
'menu_ids' => 'required|array',
'menu_ids.*' => 'integer',
]);
$this->sidebarMenuService->reorderFavorites(
auth()->id(),
$request->input('menu_ids')
);
return response()->json(['success' => true]);
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Http\Controllers;
use App\Models\Finance\BankAccount;
use App\Models\Finance\CorporateCard;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantSetting;
use App\Services\ApprovalService;
use App\Services\HR\LeaveService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class ApprovalController extends Controller
{
public function __construct(
private readonly ApprovalService $service
) {}
/**
* 기안함
*/
public function drafts(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('approvals.drafts'));
}
return view('approvals.drafts');
}
/**
* 기안 작성
*/
public function create(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('approvals.create'));
}
$forms = $this->service->getApprovalForms();
$lines = $this->service->getApprovalLines();
[$cards, $accounts] = $this->getCardAndAccountData();
$employees = app(LeaveService::class)->getActiveEmployees();
$tenantInfo = $this->getTenantInfo();
return view('approvals.create', compact('forms', 'lines', 'cards', 'accounts', 'employees', 'tenantInfo'));
}
/**
* 기안 수정
*/
public function edit(Request $request, int $id): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('approvals.edit', $id));
}
$approval = $this->service->getApproval($id);
if (! $approval->isEditable() || $approval->drafter_id !== auth()->id()) {
abort(403, '수정할 수 없습니다.');
}
$forms = $this->service->getApprovalForms();
$lines = $this->service->getApprovalLines();
[$cards, $accounts] = $this->getCardAndAccountData();
$employees = app(LeaveService::class)->getActiveEmployees();
$tenantInfo = $this->getTenantInfo();
return view('approvals.edit', compact('approval', 'forms', 'lines', 'cards', 'accounts', 'employees', 'tenantInfo'));
}
/**
* 결재 상세
*/
public function show(Request $request, int $id): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('approvals.show', $id));
}
$approval = $this->service->getApproval($id);
return view('approvals.show', compact('approval'));
}
/**
* 결재 대기함
*/
public function pending(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('approvals.pending'));
}
return view('approvals.pending');
}
/**
* 참조함
*/
public function references(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('approvals.references'));
}
return view('approvals.references');
}
/**
* 완료함
*/
public function completed(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('approvals.completed'));
}
return view('approvals.completed');
}
private function getTenantInfo(): array
{
$tenantId = session('selected_tenant_id');
$tenant = Tenant::find($tenantId);
if (! $tenant) {
return [];
}
$displaySetting = TenantSetting::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('setting_group', 'company')
->where('setting_key', 'display_company_name')
->first();
$displayName = $displaySetting?->setting_value ?? '';
return [
'company_name' => ! empty($displayName) ? $displayName : ($tenant->company_name ?? ''),
'business_num' => $tenant->business_num ?? '',
'ceo_name' => $tenant->ceo_name ?? '',
'address' => $tenant->address ?? '',
'phone' => $tenant->phone ?? '',
];
}
private function getCardAndAccountData(): array
{
$tenantId = session('selected_tenant_id');
$cards = CorporateCard::forTenant($tenantId)
->active()
->where('card_name', 'not like', '%하이패스%')
->select('id', 'card_name', 'card_company', 'card_number', 'card_holder_name')
->get();
$accounts = BankAccount::where('tenant_id', $tenantId)
->active()
->ordered()
->select('id', 'bank_name', 'account_number', 'account_holder', 'is_primary')
->get();
return [$cards, $accounts];
}
}

View File

@@ -16,6 +16,18 @@
*/
class BarobillController extends Controller
{
/**
* 바로빌 개발문서 페이지
*/
public function devGuide(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('barobill.dev-guide.index'));
}
return view('barobill.dev-guide');
}
/**
* 바로빌 설정 페이지
* HTMX 요청 시 전체 페이지 리로드 (스크립트 로딩을 위해)

View File

@@ -92,7 +92,7 @@ private function initSoapClient(): void
'exceptions' => true,
'connection_timeout' => 30,
'stream_context' => $context,
'cache_wsdl' => WSDL_CACHE_NONE,
'cache_wsdl' => WSDL_CACHE_BOTH,
]);
} catch (\Throwable $e) {
Log::error('바로빌 계좌 SOAP 클라이언트 생성 실패: '.$e->getMessage());
@@ -316,6 +316,28 @@ public function latestBalances(Request $request): JsonResponse
*/
public function transactions(Request $request): JsonResponse
{
// SOAP API 호출이 여러 건 발생할 수 있으므로 타임아웃 연장
if (function_exists('set_time_limit') && ! in_array('set_time_limit', explode(',', ini_get('disable_functions')))) {
@set_time_limit(120);
}
// SOAP 호출 시 소켓 타임아웃도 연장
$originalSocketTimeout = ini_get('default_socket_timeout');
@ini_set('default_socket_timeout', '120');
// PHP 프로세스 크래시 감지용 shutdown handler
register_shutdown_function(function () {
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR])) {
Log::error('[Eaccount] PHP Fatal Error 감지', [
'type' => $error['type'],
'message' => $error['message'],
'file' => $error['file'],
'line' => $error['line'],
]);
}
});
try {
$startDate = $request->input('startDate', date('Ymd'));
$endDate = $request->input('endDate', date('Ymd'));
@@ -426,12 +448,21 @@ public function transactions(Request $request): JsonResponse
],
]);
} catch (\Throwable $e) {
Log::error('입출금내역 조회 오류: '.$e->getMessage());
Log::error('입출금내역 조회 오류: '.$e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'error' => '서버 오류: '.$e->getMessage(),
'error' => '서버 오류: '.$e->getMessage().' ('.$e->getFile().':'.$e->getLine().')',
]);
} finally {
// 소켓 타임아웃 복원
if (isset($originalSocketTimeout)) {
@ini_set('default_socket_timeout', $originalSocketTimeout);
}
}
}
@@ -562,8 +593,10 @@ private function parseTransactionLogs($resultData, string $defaultBankName = '',
$withdraw = (int) floatval($log->Withdraw ?? 0);
$balance = (int) floatval($log->Balance ?? 0);
$summary = $log->TransRemark1 ?? $log->Summary ?? '';
$remark2 = $log->TransRemark2 ?? '';
$cleanSummary = BankTransaction::cleanSummary($summary, $remark2);
$uniqueKey = implode('|', [$bankAccountNum, $transDT, $deposit, $withdraw, $balance, $summary]);
$uniqueKey = implode('|', [$bankAccountNum, $transDT, $deposit, $withdraw, $balance, $cleanSummary]);
$uniqueKeys[] = $uniqueKey;
}
@@ -596,15 +629,18 @@ private function parseTransactionLogs($resultData, string $defaultBankName = '',
$remark2 = $log->TransRemark2 ?? '';
$transType = $log->TransType ?? '';
// ★ 적요에서 상대계좌예금주명(remark2) 중복 제거
$cleanSummary = BankTransaction::cleanSummary($summary, $remark2);
$bankAccountNum = $log->BankAccountNum ?? '';
// 고유 키 생성하여 저장된 데이터와 매칭 (숫자는 정수로 변환하여 형식 통일)
$uniqueKey = implode('|', [$bankAccountNum, $transDT, (int) $deposit, (int) $withdraw, (int) $balance, $summary]);
$uniqueKey = implode('|', [$bankAccountNum, $transDT, (int) $deposit, (int) $withdraw, (int) $balance, $cleanSummary]);
$savedItem = $savedData?->get($uniqueKey);
$override = $overrides->get($uniqueKey);
// 원본 적요/내용 (remark2를 합산하지 않음 - 상대계좌예금주명 컬럼에서 별도 표시)
$originalSummary = $summary;
$originalSummary = $cleanSummary;
$originalCast = $savedItem?->cast ?? $remark2;
// 오버라이드 적용 (수정된 값이 있으면 사용)
@@ -712,8 +748,8 @@ private function splitDateRangeMonthly(string $startDate, string $endDate): arra
'end' => $chunkEnd->format('Ymd'),
];
// 다음 월 1일로 이동
$cursor = $chunkEnd->copy()->addDay()->startOfMonth();
// 다음 월 1일로 이동 (부분 월에서도 정상 작동)
$cursor = $chunkStart->copy()->addMonth()->startOfMonth();
}
return $chunks;
@@ -850,6 +886,9 @@ private function cacheApiTransactions(int $tenantId, string $accNum, string $ban
$summary = $log->TransRemark1 ?? $log->Summary ?? '';
$remark2 = $log->TransRemark2 ?? '';
// ★ 적요에서 상대계좌예금주명(remark2) 중복 제거
$cleanSummary = BankTransaction::cleanSummary($summary, $remark2);
$rows[] = [
'tenant_id' => $tenantId,
'bank_account_num' => $log->BankAccountNum ?? $accNum,
@@ -861,7 +900,7 @@ private function cacheApiTransactions(int $tenantId, string $accNum, string $ban
'deposit' => $deposit,
'withdraw' => $withdraw,
'balance' => $balance,
'summary' => $summary,
'summary' => $cleanSummary,
'cast' => $remark2,
'memo' => $log->Memo ?? '',
'trans_office' => $log->TransOffice ?? '',
@@ -2032,11 +2071,15 @@ private function callSoap(string $method, array $params = []): array
}
try {
Log::info("바로빌 계좌 API 호출 - Method: {$method}, CorpNum: {$this->corpNum}");
Log::info("바로빌 계좌 API 호출 시작 - Method: {$method}, CorpNum: {$this->corpNum}");
$soapStartTime = microtime(true);
$result = $this->soapClient->$method($params);
$resultProperty = $method.'Result';
$elapsed = round((microtime(true) - $soapStartTime) * 1000);
Log::info("바로빌 계좌 API 완료 - Method: {$method}, 소요시간: {$elapsed}ms");
if (isset($result->$resultProperty)) {
$resultData = $result->$resultProperty;

View File

@@ -91,7 +91,7 @@ private function initSoapClient(): void
'exceptions' => true,
'connection_timeout' => 30,
'stream_context' => $context,
'cache_wsdl' => WSDL_CACHE_NONE,
'cache_wsdl' => WSDL_CACHE_BOTH,
]);
} catch (\Throwable $e) {
Log::error('바로빌 카드 SOAP 클라이언트 생성 실패: '.$e->getMessage());
@@ -1006,6 +1006,12 @@ public function save(Request $request): JsonResponse
'modified_supply_amount' => $data['modified_supply_amount'],
'modified_tax' => $data['modified_tax'],
]);
// 금액 변경 시 기존 분개 자료의 차변/대변 금액도 자동 갱신
if ($amountChanged) {
$this->syncJournalAmounts($tenantId, $uniqueKey, $newSupply, $newTax, $data['deduction_type']);
}
$updated++;
} else {
CardTransaction::create($data);
@@ -1846,6 +1852,132 @@ public function hiddenTransactions(Request $request): JsonResponse
}
}
/**
* 카드 금액 변경 시 기존 분개 자료의 차변/대변 금액 자동 갱신
*/
private function syncJournalAmounts(int $tenantId, string $uniqueKey, float $newSupply, float $newTax, ?string $deductionType): void
{
$journal = JournalEntry::getJournalBySourceKey($tenantId, 'ecard_transaction', $uniqueKey);
if (! $journal) {
return;
}
$lines = $journal->lines()->get();
if ($lines->isEmpty()) {
return;
}
$isDeductible = ($deductionType ?? 'non_deductible') === 'deductible';
$totalAmount = (int) round($newSupply + $newTax);
$supplyInt = (int) round($newSupply);
$taxInt = (int) round($newTax);
// 기존 라인의 계정과목/적요를 보존하면서 금액만 갱신
$debitLines = $lines->where('dc_type', 'debit')->values();
$creditLines = $lines->where('dc_type', 'credit')->values();
// 기존 라인 삭제 후 재생성 (금액 갱신)
$journal->lines()->delete();
$lineNo = 1;
if ($isDeductible && $debitLines->count() >= 2) {
// 공제: 비용 계정(공급가액) + 부가세대급금(세액)
$expenseLine = $debitLines->first(fn ($l) => $l->account_code !== '135') ?? $debitLines[0];
$taxLine = $debitLines->first(fn ($l) => $l->account_code === '135') ?? $debitLines[1];
JournalEntryLine::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $journal->id,
'line_no' => $lineNo++,
'dc_type' => 'debit',
'account_code' => $expenseLine->account_code,
'account_name' => $expenseLine->account_name,
'debit_amount' => $supplyInt,
'credit_amount' => 0,
'trading_partner_id' => $expenseLine->trading_partner_id,
'trading_partner_name' => $expenseLine->trading_partner_name,
'description' => $expenseLine->description,
]);
JournalEntryLine::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $journal->id,
'line_no' => $lineNo++,
'dc_type' => 'debit',
'account_code' => $taxLine->account_code,
'account_name' => $taxLine->account_name,
'debit_amount' => $taxInt,
'credit_amount' => 0,
'trading_partner_id' => $taxLine->trading_partner_id,
'trading_partner_name' => $taxLine->trading_partner_name,
'description' => $taxLine->description,
]);
} elseif ($isDeductible) {
// 공제인데 기존 라인이 1개뿐이면 기본 구조로 생성
$expenseAccount = $debitLines->first();
JournalEntryLine::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $journal->id,
'line_no' => $lineNo++,
'dc_type' => 'debit',
'account_code' => $expenseAccount?->account_code ?? '826',
'account_name' => $expenseAccount?->account_name ?? '잡비',
'debit_amount' => $supplyInt,
'credit_amount' => 0,
'trading_partner_id' => $expenseAccount?->trading_partner_id,
'trading_partner_name' => $expenseAccount?->trading_partner_name,
'description' => $expenseAccount?->description,
]);
JournalEntryLine::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $journal->id,
'line_no' => $lineNo++,
'dc_type' => 'debit',
'account_code' => '135',
'account_name' => '부가세대급금',
'debit_amount' => $taxInt,
'credit_amount' => 0,
]);
} else {
// 불공제: 비용 계정 = 공급가액 + 세액
$expenseAccount = $debitLines->first();
JournalEntryLine::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $journal->id,
'line_no' => $lineNo++,
'dc_type' => 'debit',
'account_code' => $expenseAccount?->account_code ?? '826',
'account_name' => $expenseAccount?->account_name ?? '잡비',
'debit_amount' => $totalAmount,
'credit_amount' => 0,
'trading_partner_id' => $expenseAccount?->trading_partner_id,
'trading_partner_name' => $expenseAccount?->trading_partner_name,
'description' => $expenseAccount?->description,
]);
}
// 대변: 미지급비용 (기존 대변 라인의 계정 보존)
$creditAccount = $creditLines->first();
JournalEntryLine::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $journal->id,
'line_no' => $lineNo,
'dc_type' => 'credit',
'account_code' => $creditAccount?->account_code ?? '205',
'account_name' => $creditAccount?->account_name ?? '미지급비용',
'debit_amount' => 0,
'credit_amount' => $totalAmount,
'trading_partner_id' => $creditAccount?->trading_partner_id,
'trading_partner_name' => $creditAccount?->trading_partner_name,
'description' => $creditAccount?->description,
]);
// 분개 헤더 합계 갱신
$journal->update([
'total_debit' => $totalAmount,
'total_credit' => $totalAmount,
]);
}
// ================================================================
// 카드거래 복식부기 분개 API (journal_entries 통합)
// ================================================================

View File

@@ -77,7 +77,7 @@ private function initSoapClient(): void
'exceptions' => true,
'connection_timeout' => 30,
'stream_context' => $context,
'cache_wsdl' => WSDL_CACHE_NONE,
'cache_wsdl' => WSDL_CACHE_BOTH,
]);
} catch (\Throwable $e) {
Log::error('바로빌 SOAP 클라이언트 생성 실패: '.$e->getMessage());

View File

@@ -91,7 +91,7 @@ private function initSoapClient(): void
'exceptions' => true,
'connection_timeout' => 30,
'stream_context' => $context,
'cache_wsdl' => WSDL_CACHE_NONE,
'cache_wsdl' => WSDL_CACHE_BOTH,
]);
} catch (\Throwable $e) {
Log::error('바로빌 홈택스 SOAP 클라이언트 생성 실패: '.$e->getMessage());

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Barobill;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillMember;
use App\Models\Tenants\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class SmsController extends Controller
{
/**
* SMS 발송 테스트
*/
public function send(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('barobill.sms.send'));
}
$tenantId = session('selected_tenant_id', 1);
$currentTenant = Tenant::find($tenantId);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
return view('barobill.sms.send.index', compact('currentTenant', 'barobillMember'));
}
}

View File

@@ -98,7 +98,7 @@ public function export(Request $request): JsonResponse
{
// API Key 검증
$apiKey = $request->header('X-Menu-Sync-Key');
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
$validKey = config('app.menu_sync_api_key');
if (empty($validKey) || $apiKey !== $validKey) {
return response()->json(['error' => 'Unauthorized'], 401);
@@ -125,7 +125,7 @@ public function import(Request $request): JsonResponse
{
// API Key 검증
$apiKey = $request->header('X-Menu-Sync-Key');
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
$validKey = config('app.menu_sync_api_key');
if (empty($validKey) || $apiKey !== $validKey) {
return response()->json(['error' => 'Unauthorized'], 401);

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\ChinaTech;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
/**
* 중국의 기술도약 > 5대 신흥빅테크 컨트롤러
*/
class BigTechController extends Controller
{
/**
* 5대 신흥빅테크 메인 페이지 (탭 UI)
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('china-tech.big-tech.index'));
}
return view('china-tech.big-tech.index');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\ChinaTech;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
/**
* 중국의 기술도약 > 중국 AI기술 컨트롤러
*/
class ChinaAiController extends Controller
{
/**
* 중국 AI기술 발전과정 페이지
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('china-tech.ai.index'));
}
return view('china-tech.ai.index');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\ClaudeCode;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class CoworkController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('claude-code.cowork.index'));
}
return view('claude-code.cowork.index');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\ClaudeCode;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
/**
* Claude Code > 발전과정 컨트롤러
*/
class HistoryController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('claude-code.history.index'));
}
return view('claude-code.history.index');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\ClaudeCode;
use App\Http\Controllers\Controller;
use App\Services\ClaudeCodeNewsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class NewsController extends Controller
{
public function __construct(
private ClaudeCodeNewsService $newsService
) {}
/**
* Claude Code 뉴스 (GitHub Releases) 목록
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('claude-code.news.index'));
}
$releases = $this->newsService->getReleases();
return view('claude-code.news.index', compact('releases'));
}
/**
* 캐시 새로고침
*/
public function refreshCache(): RedirectResponse
{
$this->newsService->clearCache();
return redirect()->route('claude-code.news.index')
->with('success', '캐시가 새로고침되었습니다.');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\ClaudeCode;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PricingController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('claude-code.pricing.index'));
}
return view('claude-code.pricing.index');
}
public function download(): BinaryFileResponse
{
$path = public_path('downloads/claude-code-pricing.pptx');
abort_unless(file_exists($path), 404, 'PPTX 파일을 찾을 수 없습니다.');
return response()->download($path, 'Claude_Code_요금정책_비교분석.pptx');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\ClaudeCode;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class UsagePlanController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('claude-code.usage-plan.index'));
}
return view('claude-code.usage-plan.index');
}
public function download(): BinaryFileResponse
{
$path = public_path('downloads/sam-usage-plan.pptx');
abort_unless(file_exists($path), 404, 'PPTX 파일을 찾을 수 없습니다.');
return response()->download($path, 'SAM_활용방안.pptx');
}
}

View File

@@ -97,7 +97,7 @@ public function export(Request $request): JsonResponse
{
// API Key 검증
$apiKey = $request->header('X-Menu-Sync-Key');
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
$validKey = config('app.menu_sync_api_key');
if (empty($validKey) || $apiKey !== $validKey) {
return response()->json(['error' => 'Unauthorized'], 401);
@@ -124,7 +124,7 @@ public function import(Request $request): JsonResponse
{
// API Key 검증
$apiKey = $request->header('X-Menu-Sync-Key');
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
$validKey = config('app.menu_sync_api_key');
if (empty($validKey) || $apiKey !== $validKey) {
return response()->json(['error' => 'Unauthorized'], 401);

View File

@@ -17,6 +17,18 @@
*/
class CreditController extends Controller
{
/**
* 신용평가 개발문서 페이지
*/
public function devGuide(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('credit.dev-guide.index'));
}
return view('credit.dev-guide');
}
/**
* 신용평가 조회 이력 목록
*/

View File

@@ -6,6 +6,7 @@
use App\Models\Documents\DocumentData;
use App\Models\DocumentTemplate;
use App\Models\Items\Item;
use App\Services\BlockRendererService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
@@ -80,6 +81,7 @@ public function create(Request $request): View|Response
'templates' => $templates,
'isCreate' => true,
'linkedItemSpecs' => $template ? $this->getLinkedItemSpecs($template) : [],
'blockHtml' => $template ? $this->renderBlockHtml($template, null) : '',
]);
}
@@ -123,6 +125,7 @@ public function edit(int $id): View|Response
'templates' => $templates,
'isCreate' => false,
'linkedItemSpecs' => $this->getLinkedItemSpecs($document->template),
'blockHtml' => $this->renderBlockHtml($document->template, $document),
]);
}
@@ -165,6 +168,7 @@ public function print(int $id): View
return view('documents.print', [
'document' => $document,
'workOrderItems' => $workOrderItems,
'blockHtml' => $this->renderBlockHtml($document->template, $document, 'print'),
]);
}
@@ -267,6 +271,22 @@ public function show(int $id): View
// 기본정보 bf_ 자동 backfill
$this->resolveAndBackfillBasicFields($document);
// 절곡 작업일지용: bending_info 추출
$bendingInfo = null;
if ($workOrder) {
$woOptions = json_decode($workOrder->options ?? '{}', true);
$bendingInfo = $woOptions['bending_info'] ?? null;
}
// 절곡 중간검사용: inspection_data 스냅샷 추출 (work_order_items.options.inspection_data)
$inspectionData = null;
foreach ($workOrderItems as $item) {
if (! empty($item->options['inspection_data'])) {
$inspectionData = $item->options['inspection_data'];
break;
}
}
return view('documents.show', [
'document' => $document,
'workOrderItems' => $workOrderItems,
@@ -274,6 +294,9 @@ public function show(int $id): View
'salesOrder' => $salesOrder,
'materialInputLots' => $materialInputLots,
'itemLotMap' => $itemLotMap ?? collect(),
'blockHtml' => $this->renderBlockHtml($document->template, $document, 'view'),
'bendingInfo' => $bendingInfo,
'inspectionData' => $inspectionData,
]);
}
@@ -290,7 +313,7 @@ private function resolveAndBackfillBasicFields(Document $document): void
}
// bf_ 레코드가 하나라도 있으면 이미 저장된 것 → skip
$existingBfCount = $document->data
$existingBfCount = ($document->data ?? collect())
->filter(fn ($d) => str_starts_with($d->field_key, 'bf_'))
->count();
if ($existingBfCount > 0) {
@@ -416,6 +439,30 @@ private function buildInspectionResolveMap(object $workOrder, $workOrderItems, ?
];
}
/**
* 블록 빌더 서식의 HTML 렌더링
*/
private function renderBlockHtml(DocumentTemplate $template, ?Document $document, string $mode = 'edit'): string
{
if (! $template->isBlockBuilder() || empty($template->schema)) {
return '';
}
$schema = $template->schema;
// document_data에서 field_key => field_value 맵 생성
$data = [];
if ($document && $document->data) {
foreach ($document->data as $d) {
$data[$d->field_key] = $d->field_value;
}
}
$renderer = new BlockRendererService;
return $renderer->render($schema, $mode, $data);
}
/**
* 템플릿에 연결된 품목들의 규격 정보 (thickness, width, length) 조회
*/

View File

@@ -58,6 +58,11 @@ public function edit(int $id): View
'links.linkValues',
])->findOrFail($id);
// 블록 빌더 타입이면 block-editor로 리다이렉트
if ($template->isBlockBuilder()) {
return $this->blockEdit($id);
}
// JavaScript용 데이터 변환
$templateData = $this->prepareTemplateData($template);
@@ -72,6 +77,56 @@ public function edit(int $id): View
]);
}
/**
* 블록 빌더 - 새 양식 생성
*/
public function blockCreate(Request $request): View
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('document-templates.block-create'));
}
return view('document-templates.block-editor', [
'template' => null,
'templateId' => 0,
'isCreate' => true,
'categories' => $this->getCategories(),
'initialSchema' => [
'_name' => '새 문서양식',
'_category' => '',
'version' => '1.0',
'page' => ['size' => 'A4', 'orientation' => 'portrait', 'margin' => [20, 15, 20, 15]],
'blocks' => [],
],
]);
}
/**
* 블록 빌더 - 양식 수정
*/
public function blockEdit(int $id): View
{
$template = DocumentTemplate::findOrFail($id);
$schema = $template->schema ?? [
'version' => '1.0',
'page' => $template->page_config ?? ['size' => 'A4', 'orientation' => 'portrait', 'margin' => [20, 15, 20, 15]],
'blocks' => [],
];
// 뷰에서 사용할 메타 정보 주입
$schema['_name'] = $template->name;
$schema['_category'] = $template->category ?? '';
return view('document-templates.block-editor', [
'template' => $template,
'templateId' => $template->id,
'isCreate' => false,
'categories' => $this->getCategories(),
'initialSchema' => $schema,
]);
}
/**
* 현재 선택된 테넌트 조회
*/

View File

@@ -467,29 +467,36 @@ public function store(Request $request): JsonResponse
]);
}
// 법인도장 자동 적용: GCS에서 다운로드 → 로컬 저장 → signer에 설정
// 법인도장 자동 적용: 설정에서 도장 이미지를 읽어 signer에 복사
$stampSetting = TenantSetting::where('tenant_id', $tenantId)
->where('setting_group', 'esign')
->where('setting_key', 'company_stamp')
->first();
if ($stampSetting && ! empty($stampSetting->setting_value['gcs_object'])) {
if ($stampSetting) {
$creatorSigner = EsignSigner::withoutGlobalScopes()
->where('contract_id', $contract->id)
->where('role', 'creator')
->first();
if ($creatorSigner) {
$gcs = app(GoogleCloudStorageService::class);
$signedUrl = $gcs->getSignedUrl($stampSetting->setting_value['gcs_object'], 5);
$val = $stampSetting->setting_value;
$imageData = null;
if ($signedUrl) {
$imageData = @file_get_contents($signedUrl);
if ($imageData) {
$localPath = "esign/{$tenantId}/signatures/{$contract->id}_{$creatorSigner->id}_stamp.png";
Storage::disk('local')->put($localPath, $imageData);
$creatorSigner->update(['signature_image_path' => $localPath]);
if (! empty($val['gcs_object'])) {
$gcs = app(GoogleCloudStorageService::class);
$signedUrl = $gcs->getSignedUrl($val['gcs_object'], 5);
if ($signedUrl) {
$imageData = @file_get_contents($signedUrl);
}
} elseif (! empty($val['local_path']) && Storage::disk('local')->exists($val['local_path'])) {
$imageData = Storage::disk('local')->get($val['local_path']);
}
if ($imageData) {
$localPath = "esign/{$tenantId}/signatures/{$contract->id}_{$creatorSigner->id}_stamp.png";
Storage::disk('local')->put($localPath, $imageData);
$creatorSigner->update(['signature_image_path' => $localPath]);
}
}
}
@@ -819,11 +826,14 @@ public function send(Request $request, int $id): JsonResponse
$sendMethod = $request->input('send_method', 'email');
$smsFallback = $request->boolean('sms_fallback', true);
$templateName = $request->input('template_name');
$completionTemplateName = $request->input('completion_template_name');
$contract->update([
'status' => 'pending',
'send_method' => $sendMethod,
'sms_fallback' => $smsFallback,
'completion_template_name' => $completionTemplateName,
'updated_by' => auth()->id(),
]);
@@ -838,7 +848,7 @@ public function send(Request $request, int $id): JsonResponse
$notificationResults = [];
foreach ($targetSigners as $signer) {
$signer->update(['status' => 'notified']);
$results = $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback);
$results = $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback, templateName: $templateName);
$notificationResults[] = [
'signer_id' => $signer->id,
'signer_name' => $signer->name,
@@ -966,24 +976,28 @@ private function dispatchNotification(
string $sendMethod,
bool $smsFallback,
bool $isReminder = false,
?string $templateName = null,
): array {
$results = [];
$alimtalkFailed = false;
$isCounterpart = $signer->role === EsignSigner::ROLE_COUNTERPART;
// 알림톡 발송
if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) {
$alimtalkResult = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder);
// 알림톡 발송: 상대방(counterpart)에게만 카카오톡 발송, 본사(creator)는 이메일
if (in_array($sendMethod, ['alimtalk', 'both']) && $isCounterpart && $signer->phone) {
$alimtalkResult = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder, $templateName);
$results[] = $alimtalkResult;
$alimtalkFailed = ! ($alimtalkResult['success'] ?? false);
}
// 이메일 발송 조건:
// 1) email/both 선택 시
// 2) alimtalk인데 번호 없으면 폴백
// 3) alimtalk 발송 실패 시 이메일 자동 폴백
// 2) 본사(creator)는 항상 이메일
// 3) 상대방이지만 전화번호 없으면 이메일 폴백
// 4) 알림톡 발송 실패 시 이메일 자동 폴백
$shouldSendEmail = in_array($sendMethod, ['email', 'both'])
|| ($sendMethod === 'alimtalk' && ! $signer->phone)
|| ($sendMethod === 'alimtalk' && $alimtalkFailed);
|| ! $isCounterpart
|| ($sendMethod === 'alimtalk' && $isCounterpart && ! $signer->phone)
|| ($sendMethod === 'alimtalk' && $isCounterpart && $alimtalkFailed);
if ($shouldSendEmail && $signer->email) {
try {
@@ -1010,6 +1024,7 @@ private function sendAlimtalk(
EsignSigner $signer,
bool $smsFallback = true,
bool $isReminder = false,
?string $templateName = null,
): array {
try {
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
@@ -1033,7 +1048,9 @@ private function sendAlimtalk(
$signUrl = config('app.url').'/esign/sign/'.$signer->access_token;
$expires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음';
$templateName = $isReminder ? '전자계약_리마인드' : '전자계약_서명요청';
if (! $templateName) {
$templateName = $this->resolveTemplateName($isReminder ? '전자계약_리마인드' : '전자계약_서명요청');
}
// 등록된 템플릿 본문 + 버튼 정보 조회 (정확한 포맷 유지)
$tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $templateName);
@@ -1056,12 +1073,39 @@ private function sendAlimtalk(
: " 안녕하세요, {$signer->name}님. \n 전자계약 서명 요청이 도착했습니다.\n\n ■ 계약명: {$contract->title}\n ■ 서명 기한: {$expires}\n\n 아래 버튼을 눌러 계약서를 확인하고 서명해 주세요.";
}
// 등록된 버튼 URL을 그대로 사용 (동적 URL 사용 시 템플릿 불일치 오류)
// 버튼: 템플릿에서 가져온 URL의 #{토큰}만 치환 (도메인은 템플릿 등록값 유지 — 카카오 검증)
$buttons = ! empty($templateButtons) ? $templateButtons : [
['Name' => '계약서 확인하기', 'ButtonType' => 'WL',
'Url1' => 'https://mng.codebridge-x.com', 'Url2' => 'https://mng.codebridge-x.com'],
'Url1' => $signUrl, 'Url2' => $signUrl],
];
foreach ($buttons as &$btn) {
foreach (['Url1', 'Url2'] as $urlKey) {
if (! empty($btn[$urlKey])) {
$btn[$urlKey] = str_replace(
['#{토큰}', '#{%ED%86%A0%ED%81%B0}'],
[$signer->access_token, $signer->access_token],
urldecode($btn[$urlKey])
);
}
}
}
unset($btn);
// 버튼 URL 도메인을 현재 환경의 도메인으로 치환
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
foreach ($buttons as &$btn) {
foreach (['Url1', 'Url2'] as $urlKey) {
if (! empty($btn[$urlKey])) {
$parsed = parse_url($btn[$urlKey]);
if (isset($parsed['host']) && $parsed['host'] !== $appHost) {
$btn[$urlKey] = str_replace($parsed['host'], $appHost, $btn[$urlKey]);
}
}
}
}
unset($btn);
$receiverNum = preg_replace('/[^0-9]/', '', $signer->phone);
\Log::info('E-Sign 알림톡 발송 시도', [
@@ -1180,6 +1224,14 @@ private function getKakaotalkChannelId(BarobillService $barobill, string $bizNo)
*
* @return array{content: string|null, buttons: array}
*/
/**
* 환경별 알림톡 템플릿명 반환 (운영: 원본, 개발: _DEV 접미사)
*/
private function resolveTemplateName(string $baseName): string
{
return $baseName.(app()->environment('production') ? '' : '_DEV');
}
private function getTemplateData(BarobillService $barobill, string $bizNo, string $channelId, string $templateName): array
{
$empty = ['content' => null, 'buttons' => []];
@@ -1228,6 +1280,99 @@ private function getTemplateData(BarobillService $barobill, string $bizNo, strin
return $empty;
}
/**
* 바로빌 등록 알림톡 템플릿 목록 조회 (승인 완료된 것만)
*/
public function getAlimtalkTemplates(): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', 1);
$member = BarobillMember::where('tenant_id', $tenantId)->first();
if (! $member || ! $member->biz_no) {
return response()->json([
'success' => false,
'message' => '바로빌 회원 정보 또는 사업자번호가 설정되지 않았습니다.',
]);
}
$barobill = app(BarobillService::class);
$barobill->setServerMode($member->server_mode ?? 'production');
$channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no);
if (! $channelId) {
return response()->json([
'success' => false,
'message' => '등록된 카카오톡 채널이 없습니다.',
]);
}
$result = $barobill->getKakaotalkTemplates($member->biz_no, $channelId);
if (! ($result['success'] ?? false) || empty($result['data'])) {
return response()->json([
'success' => false,
'message' => '템플릿 목록을 조회할 수 없습니다.',
]);
}
$data = $result['data'];
$items = [];
if (is_object($data) && isset($data->KakaotalkTemplate)) {
$items = is_array($data->KakaotalkTemplate)
? $data->KakaotalkTemplate
: [$data->KakaotalkTemplate];
}
// 승인(Status=3)된 템플릿만 필터링
$templates = [];
foreach ($items as $tpl) {
$status = $tpl->Status ?? null;
if ($status != 3) {
continue;
}
$buttons = [];
$btnData = $tpl->Buttons ?? null;
if ($btnData) {
$btnList = $btnData->KakaotalkButton ?? null;
if ($btnList) {
$btnList = is_array($btnList) ? $btnList : [$btnList];
foreach ($btnList as $btn) {
$buttons[] = [
'Name' => $btn->Name ?? '',
'ButtonType' => $btn->ButtonType ?? 'WL',
'Url1' => $btn->Url1 ?? '',
'Url2' => $btn->Url2 ?? '',
];
}
}
}
$templates[] = [
'name' => $tpl->TemplateName ?? '',
'content' => $tpl->TemplateContent ?? '',
'status' => $status,
'buttons' => $buttons,
];
}
return response()->json([
'success' => true,
'data' => [
'channel_id' => $channelId,
'templates' => $templates,
],
]);
} catch (\Throwable $e) {
\Log::error('알림톡 템플릿 목록 조회 실패', ['error' => $e->getMessage()]);
return response()->json([
'success' => false,
'message' => '템플릿 목록 조회 중 오류: '.$e->getMessage(),
]);
}
}
/**
* PDF 다운로드
*/

View File

@@ -9,6 +9,7 @@
use App\Models\ESign\EsignAuditLog;
use App\Models\ESign\EsignContract;
use App\Models\ESign\EsignSigner;
use App\Models\Tenants\TenantSetting;
use App\Services\Barobill\BarobillService;
use App\Services\ESign\PdfSignatureService;
use Illuminate\Http\JsonResponse;
@@ -127,11 +128,13 @@ public function getContract(string $token): JsonResponse
'id' => $signer->id,
'name' => $signer->name,
'email' => $signer->email,
'phone' => $signer->phone,
'role' => $signer->role,
'status' => $signer->status,
'has_stamp' => (bool) $signer->signature_image_path,
'has_stamp' => (bool) $signer->signature_image_path || ($signer->role === 'creator' && $this->hasCompanyStamp($contract->tenant_id)),
'signed_at' => $signer->signed_at,
],
'send_method' => $contract->tenant_id == 1 ? 'email' : ($contract->send_method ?? 'email'),
'is_signable' => $isSignable,
'status_message' => $statusMessage,
],
@@ -161,10 +164,28 @@ public function sendOtp(string $token): JsonResponse
'otp_attempts' => 0,
]);
// OTP 이메일 발송
\Illuminate\Support\Facades\Mail::to($signer->email)->send(
new \App\Mail\EsignOtpMail($signer->name, $otpCode)
);
$sendMethod = $contract->send_method ?? 'email';
$channel = 'email';
// 알림톡/both 방식이고 전화번호가 있으면 SMS로 발송 (상대방만 SMS, 본사(creator)는 이메일 유지)
if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone && $signer->role === EsignSigner::ROLE_COUNTERPART) {
$smsSent = $this->sendOtpViaSms($contract, $signer, $otpCode);
if ($smsSent) {
$channel = 'sms';
} else {
// SMS 실패 시 이메일 폴백
if ($signer->email) {
Mail::to($signer->email)->send(new \App\Mail\EsignOtpMail($signer->name, $otpCode));
$channel = 'email';
} else {
return response()->json(['success' => false, 'message' => 'OTP 발송에 실패했습니다.'], 500);
}
}
} else {
// 이메일 방식 또는 전화번호 없음
Mail::to($signer->email)->send(new \App\Mail\EsignOtpMail($signer->name, $otpCode));
$channel = 'email';
}
EsignAuditLog::create([
'tenant_id' => $contract->tenant_id,
@@ -173,11 +194,19 @@ public function sendOtp(string $token): JsonResponse
'action' => 'otp_sent',
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'metadata' => ['email' => $signer->email],
'metadata' => [
'channel' => $channel,
'email' => $channel === 'email' ? $signer->email : null,
'phone' => $channel === 'sms' ? $signer->phone : null,
],
'created_at' => now(),
]);
return response()->json(['success' => true, 'message' => '인증 코드가 발송되었습니다.']);
return response()->json([
'success' => true,
'message' => '인증 코드가 발송되었습니다.',
'channel' => $channel,
]);
}
/**
@@ -267,7 +296,15 @@ public function submitSignature(Request $request, string $token): JsonResponse
Storage::disk('local')->put($imagePath, $imageData);
$signer->update(['signature_image_path' => $imagePath]);
} elseif (! $signer->signature_image_path) {
return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422);
// 기존 계약: tenant_settings에서 법인도장 가져오기
if ($signer->role === 'creator') {
$stampPath = $this->applyCompanyStamp($signer, $contract->tenant_id);
if (! $stampPath) {
return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422);
}
} else {
return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422);
}
}
} else {
// 직접 서명: signature_image 필수
@@ -324,6 +361,7 @@ public function submitSignature(Request $request, string $token): JsonResponse
Log::error('PDF 서명 합성 실패', [
'contract_id' => $contract->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
@@ -344,9 +382,36 @@ public function submitSignature(Request $request, string $token): JsonResponse
foreach ($allSigners as $completedSigner) {
$completionResults = [];
try {
// 이메일 발송
if (in_array($sendMethod, ['email', 'both']) || ! $completedSigner->phone) {
if ($completedSigner->email) {
// 본사(creator): 이메일로 완료 알림
// 상대방(counterpart): 알림톡(카카오톡) + PDF 다운로드 링크
$isCounterpart = $completedSigner->role === EsignSigner::ROLE_COUNTERPART;
// 이메일 발송 조건:
// 1) email/both 선택 시
// 2) 본사(creator)는 항상 이메일
// 3) 상대방이지만 전화번호 없으면 이메일 폴백
$shouldSendEmail = in_array($sendMethod, ['email', 'both'])
|| ! $isCounterpart
|| ($isCounterpart && ! $completedSigner->phone);
if ($shouldSendEmail && $completedSigner->email) {
try {
Mail::to($completedSigner->email)->send(
new EsignCompletedMail($contract, $completedSigner, $allSigners)
);
$completionResults[] = ['success' => true, 'channel' => 'email', 'error' => null];
} catch (\Throwable $e) {
$completionResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
}
}
// 알림톡 발송: 상대방(counterpart)에게 카카오톡으로 서명 완료 PDF 전달
if (in_array($sendMethod, ['alimtalk', 'both']) && $isCounterpart && $completedSigner->phone) {
$alimtalkResult = $this->sendCompletionAlimtalk($contract, $completedSigner);
$completionResults[] = $alimtalkResult;
// 알림톡 실패 시 이메일 폴백 (아직 이메일 안 보낸 경우)
if (! ($alimtalkResult['success'] ?? false) && ! $shouldSendEmail && $completedSigner->email) {
try {
Mail::to($completedSigner->email)->send(
new EsignCompletedMail($contract, $completedSigner, $allSigners)
@@ -358,11 +423,6 @@ public function submitSignature(Request $request, string $token): JsonResponse
}
}
// 알림톡 발송
if (in_array($sendMethod, ['alimtalk', 'both']) && $completedSigner->phone) {
$completionResults[] = $this->sendCompletionAlimtalk($contract, $completedSigner);
}
EsignAuditLog::create([
'tenant_id' => $contract->tenant_id,
'contract_id' => $contract->id,
@@ -372,6 +432,7 @@ public function submitSignature(Request $request, string $token): JsonResponse
'user_agent' => $request->userAgent(),
'metadata' => [
'send_method' => $sendMethod,
'signer_role' => $completedSigner->role,
'notification_results' => [[
'signer_id' => $completedSigner->id,
'signer_name' => $completedSigner->name,
@@ -402,87 +463,103 @@ public function submitSignature(Request $request, string $token): JsonResponse
$nextSigner->update(['status' => 'notified']);
$nextSendMethod = $contract->send_method ?? 'alimtalk';
$nextSmsFallback = $contract->sms_fallback ?? true;
$nextIsCounterpart = $nextSigner->role === EsignSigner::ROLE_COUNTERPART;
$notificationResults = [];
$alimtalkFailed = false;
// 알림톡 발송
if (in_array($nextSendMethod, ['alimtalk', 'both']) && $nextSigner->phone) {
// 알림톡 발송: 상대방(counterpart)에게만 카카오톡 발송
if (in_array($nextSendMethod, ['alimtalk', 'both']) && $nextIsCounterpart && $nextSigner->phone) {
try {
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
if ($member) {
if ($member && $member->biz_no) {
$barobill = app(BarobillService::class);
$barobill->setServerMode($member->server_mode ?? 'production');
$nextSignUrl = config('app.url').'/esign/sign/'.$nextSigner->access_token;
$nextExpires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음';
// 채널 ID 조회
$channelResult = $barobill->getKakaotalkChannels($member->biz_no);
$yellowId = '';
if ($channelResult['success'] ?? false) {
$chData = $channelResult['data'];
if (is_object($chData) && isset($chData->KakaotalkChannel)) {
$ch = is_array($chData->KakaotalkChannel) ? $chData->KakaotalkChannel[0] : $chData->KakaotalkChannel;
$yellowId = $ch->ChannelId ?? '';
}
}
$channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no);
// 템플릿 본문 조회하여 변수 치환
$tplResult = $barobill->getKakaotalkTemplates($member->biz_no, $yellowId);
$tplMessage = null;
if ($tplResult['success'] ?? false) {
$tplData = $tplResult['data'];
$tplItems = [];
if (is_object($tplData) && isset($tplData->KakaotalkTemplate)) {
$tplItems = is_array($tplData->KakaotalkTemplate) ? $tplData->KakaotalkTemplate : [$tplData->KakaotalkTemplate];
}
foreach ($tplItems as $t) {
if (($t->TemplateName ?? '') === '전자계약_서명요청') {
$tplMessage = str_replace(
['#{이름}', '#{계약명}', '#{기한}'],
[$nextSigner->name, $contract->title, $nextExpires],
$t->TemplateContent
);
break;
if ($channelId) {
// 템플릿 본문 + 버튼 조회
$nextTemplateName = $this->resolveTemplateName('전자계약_서명요청');
$tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $nextTemplateName);
$tplMessage = $tplData['content']
? str_replace(
['#{이름}', '#{계약명}', '#{기한}'],
[$nextSigner->name, $contract->title, $nextExpires],
$tplData['content']
)
: null;
// 버튼: 템플릿에서 가져온 URL의 #{토큰} 치환
$buttons = ! empty($tplData['buttons']) ? $tplData['buttons'] : [
['Name' => '계약서 확인하기', 'ButtonType' => 'WL', 'Url1' => $nextSignUrl, 'Url2' => $nextSignUrl],
];
foreach ($buttons as &$btn) {
foreach (['Url1', 'Url2'] as $urlKey) {
if (! empty($btn[$urlKey])) {
$btn[$urlKey] = str_replace(
['#{토큰}', '#{%ED%86%A0%ED%81%B0}'],
[$nextSigner->access_token, $nextSigner->access_token],
urldecode($btn[$urlKey])
);
}
}
}
}
unset($btn);
$atResult = $barobill->sendATKakaotalkEx(
corpNum: $member->biz_no,
senderId: $member->barobill_id,
yellowId: $yellowId,
templateName: '전자계약_서명요청',
receiverName: $nextSigner->name,
receiverNum: preg_replace('/[^0-9]/', '', $nextSigner->phone),
title: '',
message: $tplMessage ?? "안녕하세요, {$nextSigner->name}님.\n전자계약 서명 요청이 도착했습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$nextExpires}\n\n아래 버튼을 눌러 계약서를 확인하고 서명해 주세요.",
buttons: [['Name' => '계약서 확인하기', 'ButtonType' => 'WL', 'Url1' => $nextSignUrl, 'Url2' => $nextSignUrl]],
smsMessage: $nextSmsFallback ? "[SAM] {$nextSigner->name}님, 전자계약 서명 요청이 도착했습니다. {$nextSignUrl}" : '',
);
$notificationResults[] = [
'success' => $atResult['success'] ?? false,
'channel' => 'alimtalk',
'error' => $atResult['error'] ?? null,
];
$atResult = $barobill->sendATKakaotalkEx(
corpNum: $member->biz_no,
senderId: $member->barobill_id,
yellowId: $channelId,
templateName: $nextTemplateName,
receiverName: $nextSigner->name,
receiverNum: preg_replace('/[^0-9]/', '', $nextSigner->phone),
title: '',
message: $tplMessage ?? "안녕하세요, {$nextSigner->name}님.\n전자계약 서명 요청이 도착했습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$nextExpires}\n\n아래 버튼을 눌러 계약서를 확인하고 서명해 주세요.",
buttons: $buttons,
smsMessage: $nextSmsFallback ? "[SAM] {$nextSigner->name}님, 전자계약 서명 요청이 도착했습니다. {$nextSignUrl}" : '',
);
$alimtalkFailed = ! ($atResult['success'] ?? false);
$notificationResults[] = [
'success' => $atResult['success'] ?? false,
'channel' => 'alimtalk',
'error' => $atResult['error'] ?? null,
];
} else {
$alimtalkFailed = true;
$notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => '등록된 카카오톡 채널 없음'];
}
} else {
$alimtalkFailed = true;
$notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록'];
}
} catch (\Throwable $e) {
Log::warning('다음 서명자 알림톡 발송 실패', ['error' => $e->getMessage()]);
$alimtalkFailed = true;
$notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()];
}
}
// 이메일 발송
if (in_array($nextSendMethod, ['email', 'both']) || ($nextSendMethod === 'alimtalk' && ! $nextSigner->phone)) {
if ($nextSigner->email) {
try {
Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner));
$notificationResults[] = ['success' => true, 'channel' => 'email', 'error' => null];
} catch (\Throwable $e) {
Log::warning('다음 서명자 이메일 발송 실패', ['error' => $e->getMessage()]);
$notificationResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
}
// 이메일 발송 조건:
// 1) email/both 선택 시
// 2) 본사(creator)는 항상 이메일
// 3) 상대방이지만 전화번호 없으면 이메일 폴백
// 4) 알림톡 발송 실패 시 이메일 자동 폴백
$shouldSendEmail = in_array($nextSendMethod, ['email', 'both'])
|| ! $nextIsCounterpart
|| ($nextSendMethod === 'alimtalk' && $nextIsCounterpart && ! $nextSigner->phone)
|| ($nextSendMethod === 'alimtalk' && $nextIsCounterpart && $alimtalkFailed);
if ($shouldSendEmail && $nextSigner->email) {
try {
Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner));
$notificationResults[] = ['success' => true, 'channel' => 'email', 'error' => null];
} catch (\Throwable $e) {
Log::warning('다음 서명자 이메일 발송 실패', ['error' => $e->getMessage()]);
$notificationResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
}
}
@@ -495,6 +572,7 @@ public function submitSignature(Request $request, string $token): JsonResponse
'user_agent' => $request->userAgent(),
'metadata' => [
'triggered_by' => 'auto_after_sign',
'signer_role' => $nextSigner->role,
'notification_results' => [[
'signer_id' => $nextSigner->id,
'signer_name' => $nextSigner->name,
@@ -561,13 +639,37 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse
// 서명 완료된 PDF가 있으면 우선 제공
if ($contract->signed_file_path && Storage::disk('local')->exists($contract->signed_file_path)) {
$filePath = $contract->signed_file_path;
} elseif ($contract->status === 'completed') {
// 계약 완료 상태인데 서명 PDF가 없으면 재생성 시도
try {
$pdfService = new PdfSignatureService;
$filePath = $pdfService->mergeSignatures($contract);
Log::info('서명 PDF 재생성 성공', ['contract_id' => $contract->id, 'path' => $filePath]);
} catch (\Throwable $e) {
Log::error('서명 PDF 재생성 실패', [
'contract_id' => $contract->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 재생성 실패 시 미리보기 PDF 폴백 (서명 제외, 텍스트/날짜/체크박스만)
try {
$filePath = $pdfService->generatePreview($contract);
} catch (\Throwable $e2) {
Log::warning('미리보기 PDF 생성도 실패, 원본 제공', ['error' => $e2->getMessage()]);
$filePath = $contract->original_file_path;
}
}
} else {
// 서명 전: 텍스트/날짜/체크박스 필드가 합성된 미리보기 PDF 생성
try {
$pdfService = new PdfSignatureService;
$filePath = $pdfService->generatePreview($contract);
} catch (\Throwable $e) {
Log::warning('미리보기 PDF 생성 실패, 원본 제공', ['error' => $e->getMessage()]);
Log::error('미리보기 PDF 생성 실패, 원본 제공', [
'contract_id' => $contract->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$filePath = $contract->original_file_path;
}
}
@@ -585,11 +687,65 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse
// ─── Private ───
/**
* SMS로 OTP 발송 (바로빌 독립 SMS API 사용)
*/
private function sendOtpViaSms(EsignContract $contract, EsignSigner $signer, string $otpCode): bool
{
try {
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
if (! $member || ! $member->manager_hp) {
Log::warning('OTP SMS 발송 실패: 바로빌 회원 또는 발신번호 없음', [
'contract_id' => $contract->id,
'tenant_id' => $contract->tenant_id,
]);
return false;
}
$barobill = app(BarobillService::class);
$barobill->setServerMode($member->server_mode ?? 'production');
$fromNumber = preg_replace('/[^0-9]/', '', $member->manager_hp);
$toNumber = preg_replace('/[^0-9]/', '', $signer->phone);
$smsText = "[SAM] 전자계약 인증코드: {$otpCode} (5분 이내 입력)";
$result = $barobill->sendSMSMessage(
corpNum: $member->biz_no,
senderId: $member->barobill_id,
fromNumber: $fromNumber,
toName: $signer->name,
toNumber: $toNumber,
contents: $smsText,
);
if ($result['success'] ?? false) {
return true;
}
Log::warning('OTP SMS 발송 API 실패', [
'contract_id' => $contract->id,
'signer_id' => $signer->id,
'error' => $result['error'] ?? 'Unknown',
]);
return false;
} catch (\Throwable $e) {
Log::error('OTP SMS 발송 예외', [
'contract_id' => $contract->id,
'signer_id' => $signer->id,
'error' => $e->getMessage(),
]);
return false;
}
}
private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $signer): array
{
try {
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
if (! $member) {
if (! $member || ! $member->biz_no) {
return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록'];
}
@@ -597,41 +753,136 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si
$barobill->setServerMode($member->server_mode ?? 'production');
// 채널 ID 조회
$channelResult = $barobill->getKakaotalkChannels($member->biz_no);
$yellowId = '';
if ($channelResult['success'] ?? false) {
$chData = $channelResult['data'];
if (is_object($chData) && isset($chData->KakaotalkChannel)) {
$ch = is_array($chData->KakaotalkChannel) ? $chData->KakaotalkChannel[0] : $chData->KakaotalkChannel;
$yellowId = $ch->ChannelId ?? '';
}
$channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no);
if (! $channelId) {
return ['success' => false, 'channel' => 'alimtalk', 'error' => '등록된 카카오톡 채널이 없습니다'];
}
$templateName = $contract->completion_template_name
?: $this->resolveTemplateName('전자계약_완료');
$documentUrl = config('app.url').'/esign/sign/'.$signer->access_token.'/api/document';
$signUrl = config('app.url').'/esign/sign/'.$signer->access_token;
$completedAt = $contract->completed_at?->format('Y-m-d H:i') ?? now()->format('Y-m-d H:i');
// 등록된 템플릿 본문 + 버튼 조회
$tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $templateName);
$templateContent = $tplData['content'];
$templateButtons = $tplData['buttons'];
if ($templateContent) {
$message = str_replace(
['#{이름}', '#{계약명}', '#{완료일}'],
[$signer->name, $contract->title, $completedAt],
$templateContent
);
} else {
Log::warning('E-Sign 완료 알림톡: 템플릿 내용 조회 실패, 하드코딩 폴백 사용', [
'template_name' => $templateName,
'channel_id' => $channelId,
]);
$message = "안녕하세요, {$signer->name}님.\n전자계약이 모든 서명자의 서명 완료로 확정되었습니다.\n\n■ 계약명: {$contract->title}\n■ 완료일: {$completedAt}\n\n아래 버튼에서 서명 완료된 계약서를 확인하고 다운로드할 수 있습니다.";
}
// 버튼: 템플릿에서 가져온 URL의 #{토큰}만 치환
$buttons = ! empty($templateButtons) ? $templateButtons : [
[
'Name' => '계약서 다운로드',
'ButtonType' => 'WL',
'Url1' => $documentUrl,
'Url2' => $documentUrl,
],
];
foreach ($buttons as &$btn) {
foreach (['Url1', 'Url2'] as $urlKey) {
if (! empty($btn[$urlKey])) {
$btn[$urlKey] = str_replace(
['#{토큰}', '#{%ED%86%A0%ED%81%B0}'],
[$signer->access_token, $signer->access_token],
urldecode($btn[$urlKey])
);
// 완료 알림톡: 버튼 URL을 문서 다운로드 엔드포인트로 강제 변경
// 템플릿 버튼 URL이 서명 페이지(/esign/sign/{token})를 가리키므로
// 완료된 계약서 PDF 다운로드(/esign/sign/{token}/api/document)로 교체
if (str_contains($btn[$urlKey], '/esign/sign/') && ! str_contains($btn[$urlKey], '/api/document')) {
$btn[$urlKey] = $documentUrl;
}
}
}
}
unset($btn);
// 버튼 URL 도메인을 현재 환경의 도메인으로 치환
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
foreach ($buttons as &$btn) {
foreach (['Url1', 'Url2'] as $urlKey) {
if (! empty($btn[$urlKey])) {
$parsed = parse_url($btn[$urlKey]);
if (isset($parsed['host']) && $parsed['host'] !== $appHost) {
$btn[$urlKey] = str_replace($parsed['host'], $appHost, $btn[$urlKey]);
}
}
}
}
unset($btn);
$receiverNum = preg_replace('/[^0-9]/', '', $signer->phone);
Log::info('E-Sign 완료 알림톡 발송 시도', [
'contract_id' => $contract->id,
'signer_id' => $signer->id,
'signer_role' => $signer->role,
'template_name' => $templateName,
'template_from_api' => (bool) $templateContent,
'buttons_from_api' => ! empty($templateButtons),
'receiver_num' => $receiverNum,
]);
$result = $barobill->sendATKakaotalkEx(
corpNum: $member->biz_no,
senderId: $member->barobill_id,
yellowId: $yellowId,
templateName: '전자계약_완료',
yellowId: $channelId,
templateName: $templateName,
receiverName: $signer->name,
receiverNum: preg_replace('/[^0-9]/', '', $signer->phone),
receiverNum: $receiverNum,
title: '',
message: "안녕하세요, {$signer->name}님.\n전자계약이 모든 서명자의 서명 완료로 확정되었습니다.\n\n■ 계약명: {$contract->title}\n■ 완료일: {$completedAt}\n\n아래 버튼에서 서명 완료된 계약서를 확인할 수 있습니다.",
buttons: [
[
'Name' => '계약서 확인하기',
'ButtonType' => 'WL',
'Url1' => $signUrl,
'Url2' => $signUrl,
],
],
message: $message,
buttons: $buttons,
smsMessage: ($contract->sms_fallback ?? true)
? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. {$signUrl}"
? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. 계약서 다운로드: {$documentUrl}"
: '',
);
// 발송 접수 후 결과 확인
if (($result['success'] ?? false) && ! empty($result['data']) && is_string($result['data'])) {
$sendKey = $result['data'];
Log::info('E-Sign 완료 알림톡 접수 성공', [
'contract_id' => $contract->id,
'send_key' => $sendKey,
]);
sleep(3);
$sendResult = $barobill->getSendKakaotalk($member->biz_no, $sendKey);
$resultData = $sendResult['data'] ?? null;
$resultCode = is_object($resultData) ? ($resultData->ResultCode ?? null) : ($resultData['ResultCode'] ?? null);
$resultMsg = is_object($resultData) ? ($resultData->ResultMessage ?? null) : ($resultData['ResultMessage'] ?? null);
Log::info('E-Sign 완료 알림톡 전달 결과', [
'contract_id' => $contract->id,
'send_key' => $sendKey,
'result_code' => $resultCode,
'result_message' => $resultMsg,
]);
if ($resultCode !== null && $resultCode != 1) {
return [
'success' => false,
'channel' => 'alimtalk',
'error' => "카카오톡 전달 실패: {$resultMsg} (코드: {$resultCode})",
];
}
}
if (! ($result['success'] ?? false)) {
Log::warning('E-Sign 완료 알림톡 발송 실패', [
'contract_id' => $contract->id,
@@ -654,6 +905,103 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si
}
}
/**
* 카카오톡 채널 ID 조회 (바로빌 API)
*/
private function getKakaotalkChannelId(BarobillService $barobill, string $bizNo): ?string
{
$result = $barobill->getKakaotalkChannels($bizNo);
if (! ($result['success'] ?? false) || empty($result['data'])) {
return null;
}
$data = $result['data'];
if (is_object($data) && isset($data->KakaotalkChannel)) {
$channels = is_array($data->KakaotalkChannel)
? $data->KakaotalkChannel
: [$data->KakaotalkChannel];
} elseif (is_array($data) && isset($data['KakaotalkChannel'])) {
$channels = is_array($data['KakaotalkChannel'])
? $data['KakaotalkChannel']
: [$data['KakaotalkChannel']];
} else {
$channels = is_array($data) ? $data : [$data];
}
$channel = $channels[0] ?? null;
if (! $channel) {
return null;
}
return is_array($channel)
? ($channel['ChannelId'] ?? null)
: ($channel->ChannelId ?? null);
}
/**
* 바로빌 등록 템플릿의 본문 내용 및 버튼 정보 조회
*
* @return array{content: string|null, buttons: array}
*/
private function getTemplateData(BarobillService $barobill, string $bizNo, string $channelId, string $templateName): array
{
$empty = ['content' => null, 'buttons' => []];
$result = $barobill->getKakaotalkTemplates($bizNo, $channelId);
if (! ($result['success'] ?? false) || empty($result['data'])) {
return $empty;
}
$data = $result['data'];
$items = [];
if (is_object($data) && isset($data->KakaotalkTemplate)) {
$items = is_array($data->KakaotalkTemplate)
? $data->KakaotalkTemplate
: [$data->KakaotalkTemplate];
}
foreach ($items as $tpl) {
if (($tpl->TemplateName ?? '') === $templateName) {
$buttons = [];
$btnData = $tpl->Buttons ?? null;
if ($btnData) {
$btnList = $btnData->KakaotalkButton ?? null;
if ($btnList) {
$btnList = is_array($btnList) ? $btnList : [$btnList];
foreach ($btnList as $btn) {
$buttons[] = [
'Name' => $btn->Name ?? '',
'ButtonType' => $btn->ButtonType ?? 'WL',
'Url1' => $btn->Url1 ?? '',
'Url2' => $btn->Url2 ?? '',
];
}
}
}
return [
'content' => $tpl->TemplateContent ?? null,
'buttons' => $buttons,
];
}
}
return $empty;
}
/**
* 환경별 알림톡 템플릿명 반환 (운영: 원본, 개발: _DEV 접미사)
*/
private function resolveTemplateName(string $baseName): string
{
return $baseName.(app()->environment('production') ? '' : '_DEV');
}
private function findSigner(string $token): ?EsignSigner
{
$signer = EsignSigner::withoutGlobalScopes()
@@ -666,4 +1014,55 @@ private function findSigner(string $token): ?EsignSigner
return $signer;
}
private function hasCompanyStamp(int $tenantId): bool
{
$stamp = TenantSetting::where('tenant_id', $tenantId)
->where('setting_group', 'esign')
->where('setting_key', 'company_stamp')
->first();
if (! $stamp) {
return false;
}
$val = $stamp->setting_value;
return ! empty($val['gcs_object']) || ! empty($val['local_path']);
}
private function applyCompanyStamp(EsignSigner $signer, int $tenantId): ?string
{
$stamp = TenantSetting::where('tenant_id', $tenantId)
->where('setting_group', 'esign')
->where('setting_key', 'company_stamp')
->first();
if (! $stamp) {
return null;
}
$val = $stamp->setting_value;
$imageData = null;
if (! empty($val['gcs_object'])) {
$gcs = app(\App\Services\GoogleCloudStorageService::class);
$signedUrl = $gcs->getSignedUrl($val['gcs_object'], 5);
if ($signedUrl) {
$imageData = @file_get_contents($signedUrl);
}
} elseif (! empty($val['local_path']) && Storage::disk('local')->exists($val['local_path'])) {
$imageData = Storage::disk('local')->get($val['local_path']);
}
if (! $imageData) {
return null;
}
$localPath = "esign/{$tenantId}/signatures/{$signer->contract_id}_{$signer->id}_stamp.png";
Storage::disk('local')->put($localPath, $imageData);
$signer->update(['signature_image_path' => $localPath]);
return $localPath;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers;
use App\Enums\InspectionCycle;
use App\Services\EquipmentInspectionService;
use App\Services\EquipmentRepairService;
use App\Services\EquipmentService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class EquipmentController extends Controller
{
public function __construct(
private EquipmentService $equipmentService,
private EquipmentInspectionService $inspectionService,
private EquipmentRepairService $repairService
) {}
public function dashboard(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('equipment.dashboard'));
}
$stats = $this->equipmentService->getDashboardStats();
$typeStats = $this->equipmentService->getTypeStats();
$inspectionStats = $this->inspectionService->getMonthlyStats(now()->format('Y-m'));
$recentRepairs = $this->repairService->getRecentRepairs(5);
return view('equipment.dashboard', compact('stats', 'typeStats', 'inspectionStats', 'recentRepairs'));
}
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('equipment.index'));
}
return view('equipment.index');
}
public function create(): View
{
$users = \App\Models\User::orderBy('name')->get(['id', 'name']);
return view('equipment.create', compact('users'));
}
public function show(int $id): View
{
$equipment = $this->equipmentService->getEquipmentById($id);
if (! $equipment) {
abort(404, '설비를 찾을 수 없습니다.');
}
return view('equipment.show', compact('equipment'));
}
public function edit(int $id): View
{
$users = \App\Models\User::orderBy('name')->get(['id', 'name']);
return view('equipment.edit', compact('id', 'users'));
}
public function inspections(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('equipment.inspections'));
}
$equipmentList = $this->equipmentService->getEquipmentList();
$cycles = InspectionCycle::all();
return view('equipment.inspections.index', compact('equipmentList', 'cycles'));
}
public function repairs(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('equipment.repairs'));
}
$equipmentList = $this->equipmentService->getEquipmentList();
return view('equipment.repairs.index', compact('equipmentList'));
}
public function repairCreate(): View
{
$equipmentList = $this->equipmentService->getEquipmentList();
$users = \App\Models\User::orderBy('name')->get(['id', 'name']);
return view('equipment.repairs.create', compact('equipmentList', 'users'));
}
public function import(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('equipment.import'));
}
return view('equipment.import');
}
public function guide(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('equipment.guide'));
}
return view('equipment.guide');
}
}

View File

@@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Finance\CondolenceExpense;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class CondolenceExpenseController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.condolence-expenses'));
}
return view('finance.condolence-expenses');
}
public function list(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$query = CondolenceExpense::forTenant($tenantId);
if ($year = $request->input('year')) {
$query->whereYear('event_date', $year);
}
if ($category = $request->input('category')) {
if ($category !== 'all') {
$query->where('category', $category);
}
}
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('partner_name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('memo', 'like', "%{$search}%");
});
}
$records = $query->orderBy('event_date', 'desc')
->orderBy('id', 'desc')
->get()
->map(fn ($item) => [
'id' => $item->id,
'event_date' => $item->event_date?->format('Y-m-d'),
'expense_date' => $item->expense_date?->format('Y-m-d'),
'partner_name' => $item->partner_name,
'description' => $item->description,
'category' => $item->category,
'has_cash' => $item->has_cash,
'cash_method' => $item->cash_method,
'cash_amount' => $item->cash_amount,
'has_gift' => $item->has_gift,
'gift_type' => $item->gift_type,
'gift_amount' => $item->gift_amount,
'total_amount' => $item->total_amount,
'memo' => $item->memo,
]);
$all = CondolenceExpense::forTenant($tenantId);
if ($year) {
$all = $all->whereYear('event_date', $year);
}
$all = $all->get();
$stats = [
'totalCount' => $all->count(),
'totalAmount' => $all->sum('total_amount'),
'cashTotal' => $all->sum('cash_amount'),
'giftTotal' => $all->sum('gift_amount'),
'congratulationCount' => $all->where('category', 'congratulation')->count(),
'condolenceCount' => $all->where('category', 'condolence')->count(),
];
return response()->json([
'success' => true,
'data' => $records,
'stats' => $stats,
]);
}
public function store(Request $request): JsonResponse
{
$request->validate([
'partner_name' => 'required|string|max:100',
'category' => 'required|in:congratulation,condolence',
]);
$tenantId = session('selected_tenant_id', 1);
$cashAmount = (int) $request->input('cash_amount', 0);
$giftAmount = (int) $request->input('gift_amount', 0);
CondolenceExpense::create([
'tenant_id' => $tenantId,
'event_date' => $request->input('event_date'),
'expense_date' => $request->input('expense_date'),
'partner_name' => $request->input('partner_name'),
'description' => $request->input('description'),
'category' => $request->input('category'),
'has_cash' => $request->boolean('has_cash'),
'cash_method' => $request->input('cash_method'),
'cash_amount' => $cashAmount,
'has_gift' => $request->boolean('has_gift'),
'gift_type' => $request->input('gift_type'),
'gift_amount' => $giftAmount,
'total_amount' => $cashAmount + $giftAmount,
'memo' => $request->input('memo'),
'created_by' => auth()->id(),
]);
return response()->json([
'success' => true,
'message' => '경조사비가 등록되었습니다.',
]);
}
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$item = CondolenceExpense::forTenant($tenantId)->findOrFail($id);
$request->validate([
'partner_name' => 'required|string|max:100',
'category' => 'required|in:congratulation,condolence',
]);
$cashAmount = (int) $request->input('cash_amount', 0);
$giftAmount = (int) $request->input('gift_amount', 0);
$item->update([
'event_date' => $request->input('event_date'),
'expense_date' => $request->input('expense_date'),
'partner_name' => $request->input('partner_name'),
'description' => $request->input('description'),
'category' => $request->input('category'),
'has_cash' => $request->boolean('has_cash'),
'cash_method' => $request->input('cash_method'),
'cash_amount' => $cashAmount,
'has_gift' => $request->boolean('has_gift'),
'gift_type' => $request->input('gift_type'),
'gift_amount' => $giftAmount,
'total_amount' => $cashAmount + $giftAmount,
'memo' => $request->input('memo'),
]);
return response()->json([
'success' => true,
'message' => '경조사비가 수정되었습니다.',
]);
}
public function destroy(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$item = CondolenceExpense::forTenant($tenantId)->findOrFail($id);
$item->delete();
return response()->json([
'success' => true,
'message' => '경조사비가 삭제되었습니다.',
]);
}
}

View File

@@ -278,6 +278,8 @@ public function updatePrepayment(Request $request): JsonResponse
'items.*.date' => 'required|date',
'items.*.amount' => 'required|integer|min:0',
'items.*.description' => 'nullable|string|max:200',
'items.*.card_splits' => 'nullable|array',
'items.*.card_splits.*' => 'integer|min:0',
]);
$tenantId = session('selected_tenant_id', 1);

View File

@@ -5,6 +5,8 @@
use App\Http\Controllers\Controller;
use App\Models\Barobill\AccountCode;
use App\Models\Barobill\BankTransaction;
use App\Models\Barobill\CardTransaction;
use App\Models\Barobill\CardTransactionHide;
use App\Models\Finance\JournalEntry;
use App\Models\Finance\JournalEntryLine;
use App\Models\Finance\TradingPartner;
@@ -111,6 +113,7 @@ public function show(int $id): JsonResponse
'total_debit' => $entry->total_debit,
'total_credit' => $entry->total_credit,
'status' => $entry->status,
'source_type' => $entry->source_type,
'created_by_name' => $entry->created_by_name,
'attachment_note' => $entry->attachment_note,
'lines' => $entry->lines->map(function ($line) {
@@ -233,6 +236,19 @@ public function store(Request $request): JsonResponse
*/
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$entry = JournalEntry::forTenant($tenantId)->findOrFail($id);
// 출처 연결 전표 수정 제한 (카드/홈택스는 원본에서 수정, 계좌는 허용)
if ($entry->source_type && ! in_array($entry->source_type, ['manual', 'bank_transaction'])) {
$sourceLabel = $entry->source_type === 'ecard_transaction' ? '카드사용내역' : '홈택스 매출/매입';
return response()->json([
'success' => false,
'message' => "이 전표는 {$sourceLabel}에서 수정해주세요.",
], 403);
}
$request->validate([
'entry_date' => 'required|date',
'description' => 'nullable|string|max:500',
@@ -248,7 +264,6 @@ public function update(Request $request, int $id): JsonResponse
'lines.*.description' => 'nullable|string|max:300',
]);
$tenantId = session('selected_tenant_id', 1);
$lines = $request->lines;
$totalDebit = collect($lines)->sum('debit_amount');
@@ -261,9 +276,7 @@ public function update(Request $request, int $id): JsonResponse
], 422);
}
DB::transaction(function () use ($tenantId, $id, $request, $lines, $totalDebit, $totalCredit) {
$entry = JournalEntry::forTenant($tenantId)->findOrFail($id);
DB::transaction(function () use ($tenantId, $entry, $request, $lines, $totalDebit, $totalCredit) {
$entry->update([
'entry_date' => $request->entry_date,
'description' => $request->description,
@@ -861,4 +874,322 @@ public function accountCodeDestroy(int $id): JsonResponse
'message' => '계정과목이 삭제되었습니다.',
]);
}
// ================================================================
// 카드거래 기반 분개 API
// ================================================================
/**
* 카드거래 목록 조회 (DB 직접 조회 + 분개상태 병합)
*/
public function cardTransactions(Request $request): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', 1);
$startDate = $request->input('startDate', date('Ymd'));
$endDate = $request->input('endDate', date('Ymd'));
$cardNum = $request->input('cardNum', '');
$query = CardTransaction::where('tenant_id', $tenantId)
->whereBetween('use_date', [$startDate, $endDate]);
if (! empty($cardNum)) {
$query->where('card_num', $cardNum);
}
$transactions = $query->orderBy('use_date', 'desc')
->orderBy('use_time', 'desc')
->get();
// 숨김 처리된 거래 제외
$hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate);
$hiddenKeysMap = $hiddenKeys->flip();
$transactions = $transactions->filter(function ($tx) use ($hiddenKeysMap) {
return ! $hiddenKeysMap->has($tx->unique_key);
});
// 로그 데이터 변환
$logs = [];
foreach ($transactions as $tx) {
$supplyAmount = $tx->modified_supply_amount !== null
? (int) $tx->modified_supply_amount
: (int) $tx->approval_amount - (int) $tx->tax;
$taxAmount = $tx->modified_tax !== null
? (int) $tx->modified_tax
: (int) $tx->tax;
$logs[] = [
'uniqueKey' => $tx->unique_key,
'useDate' => $tx->use_date,
'useTime' => $tx->use_time,
'cardNum' => $tx->card_num,
'cardCompanyName' => $tx->card_company_name,
'approvalNum' => $tx->approval_num,
'approvalType' => $tx->approval_type,
'approvalAmount' => (int) $tx->approval_amount,
'supplyAmount' => $supplyAmount,
'taxAmount' => $taxAmount,
'merchantName' => $tx->merchant_name,
'merchantBizNum' => $tx->merchant_biz_num,
'deductionType' => $tx->deduction_type,
'accountCode' => $tx->account_code,
'accountName' => $tx->account_name,
'memo' => $tx->memo,
'description' => $tx->description,
];
}
// 각 거래의 uniqueKey 수집
$uniqueKeys = array_column($logs, 'uniqueKey');
// 분개 완료된 source_key 조회
$journaledKeys = JournalEntry::getJournaledSourceKeys($tenantId, 'ecard_transaction', $uniqueKeys);
$journaledKeysMap = array_flip($journaledKeys);
// 분개된 전표 ID 조회
$journalMap = [];
if (! empty($journaledKeys)) {
$journals = JournalEntry::where('tenant_id', $tenantId)
->where('source_type', 'ecard_transaction')
->whereIn('source_key', $journaledKeys)
->select('id', 'source_key', 'entry_no')
->get();
foreach ($journals as $j) {
$journalMap[$j->source_key] = ['id' => $j->id, 'entry_no' => $j->entry_no];
}
}
// 각 거래에 분개 상태 추가
foreach ($logs as &$log) {
$key = $log['uniqueKey'] ?? '';
$log['hasJournal'] = isset($journaledKeysMap[$key]);
$log['journalId'] = $journalMap[$key]['id'] ?? null;
$log['journalEntryNo'] = $journalMap[$key]['entry_no'] ?? null;
}
unset($log);
// 통계
$totalCount = count($logs);
$totalAmount = array_sum(array_column($logs, 'approvalAmount'));
$deductibleSum = 0;
$nonDeductibleSum = 0;
foreach ($logs as $log) {
if ($log['deductionType'] === 'non_deductible') {
$nonDeductibleSum += $log['approvalAmount'];
} else {
$deductibleSum += $log['approvalAmount'];
}
}
$journaledCount = count($journaledKeys);
// 카드 목록 (드롭다운용)
$cards = CardTransaction::where('tenant_id', $tenantId)
->select('card_num', 'card_company_name')
->distinct()
->get()
->toArray();
return response()->json([
'success' => true,
'data' => [
'logs' => $logs,
'cards' => $cards,
'summary' => [
'totalCount' => $totalCount,
'totalAmount' => $totalAmount,
'deductibleSum' => $deductibleSum,
'nonDeductibleSum' => $nonDeductibleSum,
],
'journalStats' => [
'journaledCount' => $journaledCount,
'unjournaledCount' => $totalCount - $journaledCount,
],
],
]);
} catch (\Throwable $e) {
Log::error('카드거래 목록 조회 오류: '.$e->getMessage());
return response()->json([
'success' => false,
'message' => '카드거래 목록 조회 실패: '.$e->getMessage(),
], 500);
}
}
/**
* 카드거래 기반 전표 생성
*/
public function storeFromCard(Request $request): JsonResponse
{
$request->validate([
'source_key' => 'required|string|max:255',
'entry_date' => 'required|date',
'description' => 'nullable|string|max:500',
'lines' => 'required|array|min:2',
'lines.*.dc_type' => 'required|in:debit,credit',
'lines.*.account_code' => 'required|string|max:10',
'lines.*.account_name' => 'required|string|max:100',
'lines.*.trading_partner_id' => 'nullable|integer',
'lines.*.trading_partner_name' => 'nullable|string|max:100',
'lines.*.debit_amount' => 'required|integer|min:0',
'lines.*.credit_amount' => 'required|integer|min:0',
'lines.*.description' => 'nullable|string|max:300',
]);
$tenantId = session('selected_tenant_id', 1);
$lines = $request->lines;
$totalDebit = collect($lines)->sum('debit_amount');
$totalCredit = collect($lines)->sum('credit_amount');
if ($totalDebit !== $totalCredit || $totalDebit === 0) {
return response()->json([
'success' => false,
'message' => '차변합계와 대변합계가 일치해야 하며 0보다 커야 합니다.',
], 422);
}
// 중복 분개 체크
$existing = JournalEntry::getJournalBySourceKey($tenantId, 'ecard_transaction', $request->source_key);
if ($existing) {
return response()->json([
'success' => false,
'message' => '이미 분개가 완료된 거래입니다. (전표번호: '.$existing->entry_no.')',
], 422);
}
$maxRetries = 3;
$lastError = null;
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
try {
$entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit) {
$entryNo = JournalEntry::generateEntryNo($tenantId, $request->entry_date);
$entry = JournalEntry::create([
'tenant_id' => $tenantId,
'entry_no' => $entryNo,
'entry_date' => $request->entry_date,
'description' => $request->description,
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'status' => 'draft',
'source_type' => 'ecard_transaction',
'source_key' => $request->source_key,
'created_by_name' => auth()->user()?->name ?? '시스템',
]);
foreach ($lines as $i => $line) {
JournalEntryLine::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $entry->id,
'line_no' => $i + 1,
'dc_type' => $line['dc_type'],
'account_code' => $line['account_code'],
'account_name' => $line['account_name'],
'trading_partner_id' => $line['trading_partner_id'] ?? null,
'trading_partner_name' => $line['trading_partner_name'] ?? null,
'debit_amount' => $line['debit_amount'],
'credit_amount' => $line['credit_amount'],
'description' => $line['description'] ?? null,
]);
}
return $entry;
});
return response()->json([
'success' => true,
'message' => '분개가 저장되었습니다.',
'data' => ['id' => $entry->id, 'entry_no' => $entry->entry_no],
]);
} catch (\Illuminate\Database\QueryException $e) {
$lastError = $e;
if ($e->errorInfo[1] === 1062) {
continue;
}
break;
} catch (\Throwable $e) {
$lastError = $e;
break;
}
}
Log::error('카드거래 분개 저장 오류: '.$lastError->getMessage());
return response()->json([
'success' => false,
'message' => '분개 저장 실패: '.$lastError->getMessage(),
], 500);
}
/**
* 특정 카드거래의 기존 분개 조회
*/
public function cardJournals(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$sourceKey = $request->get('source_key');
if (! $sourceKey) {
return response()->json(['success' => false, 'message' => 'source_key가 필요합니다.'], 422);
}
$entry = JournalEntry::forTenant($tenantId)
->where('source_type', 'ecard_transaction')
->where('source_key', $sourceKey)
->with('lines')
->first();
if (! $entry) {
return response()->json(['success' => true, 'data' => null]);
}
return response()->json([
'success' => true,
'data' => [
'id' => $entry->id,
'entry_no' => $entry->entry_no,
'entry_date' => $entry->entry_date->format('Y-m-d'),
'description' => $entry->description,
'total_debit' => $entry->total_debit,
'total_credit' => $entry->total_credit,
'status' => $entry->status,
'lines' => $entry->lines->map(function ($line) {
return [
'id' => $line->id,
'line_no' => $line->line_no,
'dc_type' => $line->dc_type,
'account_code' => $line->account_code,
'account_name' => $line->account_name,
'trading_partner_id' => $line->trading_partner_id,
'trading_partner_name' => $line->trading_partner_name,
'debit_amount' => $line->debit_amount,
'credit_amount' => $line->credit_amount,
'description' => $line->description,
];
}),
],
]);
}
/**
* 카드거래 분개 삭제 (soft delete)
*/
public function deleteCardJournal(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$entry = JournalEntry::forTenant($tenantId)
->where('source_type', 'ecard_transaction')
->findOrFail($id);
$entry->delete();
return response()->json([
'success' => true,
'message' => '분개가 삭제되었습니다.',
]);
}
}

View File

@@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\Barobill\HometaxInvoiceJournal;
use App\Models\Finance\JournalEntry;
use App\Models\Finance\JournalEntryLine;
use App\Models\Finance\Payable;
use Illuminate\Http\JsonResponse;
@@ -313,6 +314,7 @@ public function integrated(Request $request): JsonResponse
$journalDetails = (clone $journalQuery)
->select(
'journal_entry_lines.id',
'journal_entry_lines.journal_entry_id',
'journal_entry_lines.trading_partner_name',
'journal_entry_lines.account_code',
'journal_entry_lines.account_name',
@@ -505,4 +507,36 @@ public function journalPayables(Request $request): JsonResponse
],
]);
}
/**
* 미지급금 관련 전표 강제 삭제 (soft delete)
*/
public function deleteJournalEntry(int $id): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', 1);
$entry = JournalEntry::where('tenant_id', $tenantId)->find($id);
if (! $entry) {
return response()->json([
'success' => false,
'message' => "전표(ID: {$id})를 찾을 수 없습니다. (tenant: {$tenantId})",
], 404);
}
$entryNo = $entry->entry_no;
$entry->delete();
return response()->json([
'success' => true,
'message' => "전표 {$entryNo}이(가) 삭제되었습니다.",
]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'message' => '삭제 오류: '.$e->getMessage(),
], 500);
}
}
}

View File

@@ -523,7 +523,7 @@ public function summary(Request $request): JsonResponse
'totalCredit' => $totalCredit,
'balance' => $priorBalance + $totalDebit - $totalCredit,
'lastTransactionDate' => $group->pluck('date')->filter()->sort()->last(),
'transactionCount' => $group->count(),
'transactionCount' => $group->where('debitAmount', '>', 0)->count(),
];
});

View File

@@ -611,7 +611,7 @@ private function getPartnerSettlement(\Closure $baseQuery): \Illuminate\Support\
->select([
'id', 'partner_id', 'management_id', 'payment_type',
'partner_commission', 'manager_commission', 'referrer_commission',
'status', 'scheduled_payment_date',
'status', 'scheduled_payment_date', 'manager_user_id',
])
->orderBy('partner_id')
->orderBy('scheduled_payment_date')

View File

@@ -229,11 +229,6 @@ public function index(Request $request): JsonResponse
$cardPurchaseSupply = $cardRecords->sum('supplyAmount');
$cardPurchaseVat = $cardRecords->sum('vatAmount');
// 수동입력 매출 종이세금계산서 (과세+영세)
$manualSalesTaxable = $manualRecords->where('type', 'sales')->whereIn('taxType', ['taxable', 'zero_rated']);
$manualSalesSupply = $manualSalesTaxable->sum('supplyAmount');
$manualSalesVat = $manualSalesTaxable->sum('vatAmount');
// 수동입력 매입 종이세금계산서 (과세+영세)
$manualPurchaseTaxable = $manualRecords->where('type', 'purchase')->whereIn('taxType', ['taxable', 'zero_rated']);
$manualPurchaseSupply = $manualPurchaseTaxable->sum('supplyAmount');
@@ -247,14 +242,12 @@ public function index(Request $request): JsonResponse
$exemptSupply = $exemptSalesSupply + $exemptPurchaseSupply + $manualExemptSalesSupply + $manualExemptPurchaseSupply;
$stats = [
'salesSupply' => $hometaxSalesSupply + $manualSalesSupply,
'salesVat' => $hometaxSalesVat + $manualSalesVat,
'salesSupply' => $hometaxSalesSupply,
'salesVat' => $hometaxSalesVat,
'purchaseSupply' => $hometaxPurchaseSupply + $cardPurchaseSupply + $manualPurchaseSupply,
'purchaseVat' => $hometaxPurchaseVat + $cardPurchaseVat + $manualPurchaseVat,
'hometaxSalesSupply' => $hometaxSalesSupply,
'hometaxSalesVat' => $hometaxSalesVat,
'manualSalesSupply' => $manualSalesSupply,
'manualSalesVat' => $manualSalesVat,
'hometaxPurchaseSupply' => $hometaxPurchaseSupply,
'hometaxPurchaseVat' => $hometaxPurchaseVat,
'manualPurchaseSupply' => $manualPurchaseSupply,

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\GoogleCloud;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class AiGuideController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('google-cloud.ai-guide.index'));
}
return view('google-cloud.ai-guide.index');
}
public function download(): BinaryFileResponse
{
$path = public_path('downloads/google-cloud-ai-guide.pptx');
abort_unless(file_exists($path), 404, 'PPTX 파일을 찾을 수 없습니다.');
return response()->download($path, 'Google_Cloud_AI_활용가이드.pptx');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\GoogleCloud;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class CloudApiPricingController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('google-cloud.cloud-api-pricing.index'));
}
return view('google-cloud.cloud-api-pricing.index');
}
public function download(): BinaryFileResponse
{
$path = public_path('downloads/google-cloud-api-pricing.pptx');
abort_unless(file_exists($path), 404, 'PPTX 파일을 찾을 수 없습니다.');
return response()->download($path, 'Google_Cloud_API_요금표.pptx');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\GoogleCloud;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class WorkspacePolicyController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('google-cloud.workspace-policy.index'));
}
return view('google-cloud.workspace-policy.index');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\GoogleCloud;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class WorkspacePricingController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('google-cloud.workspace-pricing.index'));
}
return view('google-cloud.workspace-pricing.index');
}
public function download(): BinaryFileResponse
{
$path = public_path('downloads/google-workspace-pricing.pptx');
abort_unless(file_exists($path), 404, 'PPTX 파일을 찾을 수 없습니다.');
return response()->download($path, 'Google_Workspace_요금정책.pptx');
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Models\HR\Attendance;
use App\Services\HR\AttendanceService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class AttendanceController extends Controller
{
public function __construct(
private AttendanceService $attendanceService
) {}
/**
* 근태현황 목록 페이지 (조회 전용)
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('hr.attendances.index'));
}
$stats = $this->attendanceService->getMonthlyStats();
$departments = $this->attendanceService->getDepartments();
$statusMap = Attendance::STATUS_MAP;
return view('hr.attendances.index', [
'stats' => $stats,
'departments' => $departments,
'statusMap' => $statusMap,
]);
}
/**
* 근태관리 페이지 (등록/수정/삭제/승인)
*/
public function manage(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('hr.attendances.manage'));
}
$departments = $this->attendanceService->getDepartments();
$employees = $this->attendanceService->getActiveEmployees();
$statusMap = Attendance::STATUS_MAP;
return view('hr.attendances.manage', [
'departments' => $departments,
'employees' => $employees,
'statusMap' => $statusMap,
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Models\HR\Attendance;
use App\Models\HR\Leave;
use App\Services\HR\AttendanceService;
use App\Services\HR\LeaveService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class AttendanceIntegratedController extends Controller
{
public function __construct(
private AttendanceService $attendanceService,
private LeaveService $leaveService
) {}
/**
* 근태관리 통합 화면
*/
public function index(Request $request): View|Response
{
// JS 필요 페이지 → HTMX 시 전체 리로드
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('hr.attendance.index'));
}
$stats = $this->attendanceService->getMonthlyStats();
$departments = $this->leaveService->getDepartments();
$employees = $this->leaveService->getActiveEmployees();
$statusMap = Attendance::STATUS_MAP;
$leaveTypeMap = Leave::TYPE_MAP;
$leaveStatusMap = Leave::STATUS_MAP;
// 결재선 목록
$approvalLines = \App\Models\Approvals\ApprovalLine::query()
->where('tenant_id', session('selected_tenant_id'))
->orderBy('name')
->get(['id', 'name', 'is_default']);
return view('hr.attendance-integrated.index', [
'stats' => $stats,
'departments' => $departments,
'employees' => $employees,
'statusMap' => $statusMap,
'leaveTypeMap' => $leaveTypeMap,
'leaveStatusMap' => $leaveStatusMap,
'approvalLines' => $approvalLines,
]);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Models\Boards\File;
use App\Services\HR\BusinessIncomeEarnerService;
use Illuminate\Contracts\View\View;
class BusinessIncomeEarnerController extends Controller
{
public function __construct(
private BusinessIncomeEarnerService $service
) {}
public function index(): View
{
$stats = $this->service->getStats();
$departments = $this->service->getDepartments();
return view('hr.business-income-earners.index', [
'stats' => $stats,
'departments' => $departments,
]);
}
public function create(): View
{
$departments = $this->service->getDepartments();
$ranks = $this->service->getPositions('rank');
$titles = $this->service->getPositions('title');
return view('hr.business-income-earners.create', [
'departments' => $departments,
'ranks' => $ranks,
'titles' => $titles,
'banks' => config('banks', []),
]);
}
public function show(int $id): View
{
$earner = $this->service->getById($id);
if (! $earner) {
abort(404, '사업소득자 정보를 찾을 수 없습니다.');
}
$files = File::where('document_type', 'business_income_earner_profile')
->where('document_id', $earner->id)
->where('tenant_id', session('selected_tenant_id'))
->orderBy('created_at', 'desc')
->get();
return view('hr.business-income-earners.show', [
'earner' => $earner,
'files' => $files,
]);
}
public function edit(int $id): View
{
$earner = $this->service->getById($id);
if (! $earner) {
abort(404, '사업소득자 정보를 찾을 수 없습니다.');
}
$departments = $this->service->getDepartments();
$ranks = $this->service->getPositions('rank');
$titles = $this->service->getPositions('title');
$files = File::where('document_type', 'business_income_earner_profile')
->where('document_id', $earner->id)
->where('tenant_id', session('selected_tenant_id'))
->orderBy('created_at', 'desc')
->get();
return view('hr.business-income-earners.edit', [
'earner' => $earner,
'departments' => $departments,
'ranks' => $ranks,
'titles' => $titles,
'banks' => config('banks', []),
'files' => $files,
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\BusinessIncomePaymentService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class BusinessIncomePaymentController extends Controller
{
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
public function __construct(
private BusinessIncomePaymentService $service
) {}
/**
* 사업소득자 임금대장 페이지
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('hr.business-income-payments.index'));
}
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
return view('hr.payrolls.restricted');
}
$year = $request->integer('year') ?: now()->year;
$month = $request->integer('month') ?: now()->month;
$earners = $this->service->getActiveEarners();
$payments = $this->service->getPayments($year, $month);
$stats = $this->service->getMonthlyStats($year, $month);
$earnersForJs = $earners->map(fn ($e) => [
'user_id' => $e->user_id,
'business_name' => $e->business_name ?? ($e->user?->name ?? ''),
'user_name' => $e->user?->name ?? '',
'business_reg_number' => $e->business_registration_number ?? '',
])->values();
return view('hr.business-income-payments.index', [
'payments' => $payments,
'earnersForJs' => $earnersForJs,
'stats' => $stats,
'year' => $year,
'month' => $month,
]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Models\Boards\File;
use App\Services\HR\EmployeeService;
use Illuminate\Contracts\View\View;
class EmployeeController extends Controller
{
public function __construct(
private EmployeeService $employeeService
) {}
/**
* 사원 목록 페이지
*/
public function index(): View
{
$showExcluded = request()->boolean('show_excluded');
$stats = $this->employeeService->getStats($showExcluded);
$departments = $this->employeeService->getDepartments();
return view('hr.employees.index', [
'stats' => $stats,
'departments' => $departments,
]);
}
/**
* 사원 등록 폼
*/
public function create(): View
{
$departments = $this->employeeService->getDepartments();
$ranks = $this->employeeService->getPositions('rank');
$titles = $this->employeeService->getPositions('title');
return view('hr.employees.create', [
'departments' => $departments,
'ranks' => $ranks,
'titles' => $titles,
'banks' => config('banks', []),
]);
}
/**
* 사원 상세 페이지
*/
public function show(int $id): View
{
$employee = $this->employeeService->getEmployeeById($id);
if (! $employee) {
abort(404, '사원 정보를 찾을 수 없습니다.');
}
$files = File::where('document_type', 'employee_profile')
->where('document_id', $employee->id)
->where('tenant_id', session('selected_tenant_id'))
->orderBy('created_at', 'desc')
->get();
return view('hr.employees.show', [
'employee' => $employee,
'files' => $files,
]);
}
/**
* 사원 수정 폼
*/
public function edit(int $id): View
{
$employee = $this->employeeService->getEmployeeById($id);
if (! $employee) {
abort(404, '사원 정보를 찾을 수 없습니다.');
}
$departments = $this->employeeService->getDepartments();
$ranks = $this->employeeService->getPositions('rank');
$titles = $this->employeeService->getPositions('title');
$files = File::where('document_type', 'employee_profile')
->where('document_id', $employee->id)
->where('tenant_id', session('selected_tenant_id'))
->orderBy('created_at', 'desc')
->get();
return view('hr.employees.edit', [
'employee' => $employee,
'departments' => $departments,
'ranks' => $ranks,
'titles' => $titles,
'banks' => config('banks', []),
'files' => $files,
]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\EmployeeService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class EmployeeTenureController extends Controller
{
public function __construct(
private EmployeeService $employeeService
) {}
/**
* 입퇴사자 현황 페이지
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('hr.employee-tenure'));
}
$stats = $this->employeeService->getTenureStats();
$departments = $this->employeeService->getDepartments();
return view('hr.employee-tenure.index', [
'stats' => $stats,
'departments' => $departments,
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Models\Approvals\ApprovalLine;
use App\Models\HR\Leave;
use App\Services\HR\LeaveService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
class LeaveController extends Controller
{
public function __construct(
private LeaveService $leaveService
) {}
public function index(\Illuminate\Http\Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('hr.leaves.index'));
}
$employees = $this->leaveService->getActiveEmployees();
$departments = $this->leaveService->getDepartments();
$typeMap = Leave::TYPE_MAP;
$statusMap = Leave::STATUS_MAP;
$tenantId = session('selected_tenant_id', 1);
$approvalLines = ApprovalLine::where('tenant_id', $tenantId)
->orderByDesc('is_default')
->orderBy('name')
->get(['id', 'name', 'steps', 'is_default']);
return view('hr.leaves.index', [
'employees' => $employees,
'departments' => $departments,
'typeMap' => $typeMap,
'statusMap' => $statusMap,
'approvalLines' => $approvalLines,
]);
}
/**
* 휴가관리 가이드 도움말 모달
*/
public function helpGuide(): View
{
$guidePath = resource_path('markdown/휴가관리가이드.md');
if (file_exists($guidePath)) {
$markdown = file_get_contents($guidePath);
$htmlContent = Str::markdown($markdown);
} else {
$htmlContent = '<p class="text-gray-500">가이드를 찾을 수 없습니다.</p>';
}
return view('hr.leaves.partials.help-modal', compact('htmlContent'));
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\LeaveService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class LeavePromotionController extends Controller
{
public function __construct(
private LeaveService $leaveService
) {}
public function index(Request $request): \Illuminate\Contracts\View\View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('hr.leave-promotions.index'));
}
$year = (int) ($request->get('year', now()->year));
$candidates = $this->leaveService->getPromotionCandidates($year);
$stats = [
'total' => $candidates->count(),
'not_sent' => $candidates->where('promotion_status', 'not_sent')->count(),
'first_sent' => $candidates->where('promotion_status', 'first_sent')->count(),
'completed' => $candidates->where('promotion_status', 'completed')->count(),
];
return view('hr.leave-promotions.index', [
'candidates' => $candidates,
'stats' => $stats,
'year' => $year,
]);
}
public function store(Request $request)
{
$request->validate([
'employee_ids' => 'required|array|min:1',
'employee_ids.*' => 'integer',
'notice_type' => 'required|in:1st,2nd',
'deadline' => 'required_if:notice_type,1st|date',
'designated_dates' => 'nullable|array',
'designated_dates.*' => 'date',
]);
$year = (int) ($request->get('year', now()->year));
$noticeType = $request->get('notice_type');
$employeeIds = $request->get('employee_ids');
$result = $this->leaveService->sendPromotionNotices(
employeeIds: $employeeIds,
noticeType: $noticeType,
year: $year,
deadline: $request->get('deadline'),
designatedDates: $request->get('designated_dates', []),
);
return response()->json([
'success' => true,
'message' => count($result['created']).'건의 연차촉진 통지서가 생성되었습니다.',
'created' => $result['created'],
'skipped' => $result['skipped'],
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Models\HR\Payroll;
use App\Services\HR\PayrollService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class PayrollController extends Controller
{
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
public function __construct(
private PayrollService $payrollService
) {}
/**
* 급여관리 페이지
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('hr.payrolls.index'));
}
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
return view('hr.payrolls.restricted');
}
$stats = $this->payrollService->getMonthlyStats();
$departments = $this->payrollService->getDepartments();
$employees = $this->payrollService->getActiveEmployees();
$settings = $this->payrollService->getSettings();
$statusMap = Payroll::STATUS_MAP;
return view('hr.payrolls.index', [
'stats' => $stats,
'departments' => $departments,
'employees' => $employees,
'settings' => $settings,
'statusMap' => $statusMap,
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Help;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
/**
* 도움말 > 회계동작원리 컨트롤러
*/
class AccountingGuideController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('help.accounting.index'));
}
return view('help.accounting.index');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Help;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
/**
* 도움말 > 연차휴가/근태관리 컨트롤러
*/
class AttendanceGuideController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('help.attendance.index'));
}
return view('help.attendance.index');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Help;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
/**
* 도움말 > 바로빌 연동 가이드 컨트롤러
*/
class BarobillGuideController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('help.barobill.index'));
}
return view('help.barobill.index');
}
}

View File

@@ -26,4 +26,13 @@ public function project(Request $request): View|Response
return view('juil.project');
}
public function workflow(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('juil.workflow'));
}
return view('juil.workflow');
}
}

View File

@@ -85,6 +85,7 @@ public function index(Request $request): View|Response
'diff' => $diff,
'localTenantName' => $localTenant?->company_name ?? '알 수 없음',
'remoteTenantName' => $this->remoteTenantName,
'hasOrderSnapshot' => session()->has("menu_order_snapshot_{$selectedEnv}"),
]);
}
@@ -122,7 +123,7 @@ public function export(Request $request): JsonResponse
{
// API Key 검증
$apiKey = $request->header('X-Menu-Sync-Key');
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
$validKey = config('app.menu_sync_api_key');
if (empty($validKey) || $apiKey !== $validKey) {
return response()->json(['error' => 'Unauthorized'], 401);
@@ -150,7 +151,7 @@ public function import(Request $request): JsonResponse
{
// API Key 검증
$apiKey = $request->header('X-Menu-Sync-Key');
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
$validKey = config('app.menu_sync_api_key');
if (empty($validKey) || $apiKey !== $validKey) {
return response()->json(['error' => 'Unauthorized'], 401);
@@ -159,7 +160,7 @@ public function import(Request $request): JsonResponse
$validated = $request->validate([
'menus' => 'required|array',
'menus.*.name' => 'required|string|max:100',
'menus.*.url' => 'required|string|max:255',
'menus.*.url' => 'nullable|string|max:255',
'menus.*.icon' => 'nullable|string|max:50',
'menus.*.sort_order' => 'nullable|integer',
'menus.*.options' => 'nullable|array',
@@ -216,7 +217,7 @@ public function push(Request $request): JsonResponse
return [
'name' => $menu->name,
'url' => $menu->url,
'url' => $menu->url ?? '',
'icon' => $menu->icon,
'sort_order' => $menu->sort_order,
'options' => $menu->options,
@@ -230,7 +231,7 @@ public function push(Request $request): JsonResponse
$response = Http::withHeaders([
'X-Menu-Sync-Key' => $env['api_key'],
'Accept' => 'application/json',
])->post(rtrim($env['url'], '/').'/menu-sync/import', [
])->timeout(15)->post(rtrim($env['url'], '/').'/menu-sync/import', [
'menus' => $menuData,
]);
@@ -241,9 +242,11 @@ public function push(Request $request): JsonResponse
]);
}
$errorMsg = $response->json('message') ?? $response->json('error') ?? '원격 서버 오류 (HTTP '.$response->status().')';
return response()->json([
'error' => $response->json('error', '원격 서버 오류'),
], $response->status());
'error' => $errorMsg,
], 422);
} catch (\Exception $e) {
return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500);
}
@@ -379,7 +382,7 @@ private function getChildrenData(int $parentId): array
return $children->map(function ($menu) {
return [
'name' => $menu->name,
'url' => $menu->url,
'url' => $menu->url ?? '',
'icon' => $menu->icon,
'sort_order' => $menu->sort_order,
'options' => $menu->options,
@@ -470,6 +473,213 @@ private function filterMenusByName(array $menus, array $names, ?string $parentNa
return $result;
}
/**
* 순서 동기화 Push (로컬 순서 → 원격 서버)
*/
public function pushOrder(Request $request): JsonResponse
{
$validated = $request->validate([
'env' => 'required|string|in:dev,prod',
]);
$environments = $this->getEnvironments();
$env = $environments[$validated['env']] ?? null;
if (! $env || empty($env['url'])) {
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
}
try {
// 1. 원격 서버 현재 순서 스냅샷 저장 (되돌리기용)
$remoteMenus = $this->fetchRemoteMenus($env);
$snapshot = $this->buildOrderMap($remoteMenus);
session()->put("menu_order_snapshot_{$validated['env']}", $snapshot);
// 2. 로컬 메뉴 트리에서 순서 매핑 생성
$localMenus = $this->getMenuTree();
$orderMap = $this->buildOrderMap($localMenus);
// 3. 원격 서버에 순서 업데이트 전송
$response = Http::withHeaders([
'X-Menu-Sync-Key' => $env['api_key'],
'Accept' => 'application/json',
])->timeout(30)->post(rtrim($env['url'], '/').'/menu-sync/reorder', [
'menus' => $orderMap,
'tenant_id' => $this->getTenantId(),
]);
if ($response->successful()) {
return response()->json([
'success' => true,
'message' => $response->json('message', '순서 동기화 완료'),
'hasSnapshot' => true,
]);
}
// 실패 시 스냅샷 삭제
session()->forget("menu_order_snapshot_{$validated['env']}");
return response()->json([
'error' => $response->json('error', '원격 서버 오류'),
], $response->status());
} catch (\Exception $e) {
session()->forget("menu_order_snapshot_{$validated['env']}");
return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500);
}
}
/**
* 순서 동기화 되돌리기
*/
public function undoOrder(Request $request): JsonResponse
{
$validated = $request->validate([
'env' => 'required|string|in:dev,prod',
]);
$environments = $this->getEnvironments();
$env = $environments[$validated['env']] ?? null;
if (! $env || empty($env['url'])) {
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
}
$snapshot = session("menu_order_snapshot_{$validated['env']}");
if (empty($snapshot)) {
return response()->json(['error' => '되돌릴 스냅샷이 없습니다.'], 400);
}
try {
$response = Http::withHeaders([
'X-Menu-Sync-Key' => $env['api_key'],
'Accept' => 'application/json',
])->timeout(30)->post(rtrim($env['url'], '/').'/menu-sync/reorder', [
'menus' => $snapshot,
'tenant_id' => $this->getTenantId(),
]);
if ($response->successful()) {
session()->forget("menu_order_snapshot_{$validated['env']}");
return response()->json([
'success' => true,
'message' => '순서가 이전 상태로 복원되었습니다.',
'hasSnapshot' => false,
]);
}
return response()->json([
'error' => $response->json('error', '원격 서버 오류'),
], $response->status());
} catch (\Exception $e) {
return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500);
}
}
/**
* 메뉴 순서 재정렬 API (외부 서버에서 호출)
*/
public function reorder(Request $request): JsonResponse
{
$apiKey = $request->header('X-Menu-Sync-Key');
$validKey = config('app.menu_sync_api_key');
if (empty($validKey) || $apiKey !== $validKey) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$validated = $request->validate([
'menus' => 'required|array',
'tenant_id' => 'nullable|integer',
]);
$tenantId = $validated['tenant_id'] ?? $this->getTenantId();
$updated = $this->applyOrder($validated['menus'], $tenantId);
return response()->json([
'success' => true,
'message' => "{$updated}개 메뉴 순서가 업데이트되었습니다.",
'updated' => $updated,
]);
}
/**
* 메뉴 트리를 순서 매핑 배열로 변환
*/
private function buildOrderMap(array $menus, ?string $parentName = null): array
{
$result = [];
foreach ($menus as $menu) {
$item = [
'name' => $menu['name'],
'parent_name' => $parentName,
'sort_order' => $menu['sort_order'] ?? 0,
'children' => [],
];
if (! empty($menu['children'])) {
$item['children'] = $this->buildOrderMap($menu['children'], $menu['name']);
}
$result[] = $item;
}
return $result;
}
/**
* 순서 매핑을 DB에 적용 (재귀)
*/
private function applyOrder(array $orderMap, int $tenantId, ?int $parentId = null): int
{
$updated = 0;
foreach ($orderMap as $item) {
$query = Menu::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('name', $item['name']);
// parent_name으로 부모 매칭
if (! empty($item['parent_name'])) {
$parent = Menu::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('name', $item['parent_name'])
->first();
$resolvedParentId = $parent?->id;
} else {
$resolvedParentId = null;
}
// parentId 파라미터가 있으면 우선 사용 (재귀 호출 시)
$targetParentId = $parentId ?? $resolvedParentId;
$query->where('parent_id', $targetParentId);
$menu = $query->first();
if ($menu) {
$changes = [];
if ($menu->sort_order !== ($item['sort_order'] ?? 0)) {
$changes['sort_order'] = $item['sort_order'] ?? 0;
}
if ($menu->parent_id !== $targetParentId) {
$changes['parent_id'] = $targetParentId;
}
if (! empty($changes)) {
$menu->update($changes);
$updated++;
}
// 자식 메뉴 처리
if (! empty($item['children'])) {
$updated += $this->applyOrder($item['children'], $tenantId, $menu->id);
}
}
}
return $updated;
}
/**
* 메뉴 Import
*/
@@ -492,7 +702,7 @@ private function importMenu(array $data, ?int $parentId = null): void
'parent_id' => $parentId,
],
[
'url' => $data['url'],
'url' => ! empty($data['url']) ? $data['url'] : null,
'icon' => $data['icon'] ?? null,
'sort_order' => $data['sort_order'] ?? 0,
'options' => $data['options'] ?? null,

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Mobile;
use App\Enums\InspectionCycle;
use App\Http\Controllers\Controller;
use App\Models\Equipment\Equipment;
use App\Models\Equipment\EquipmentInspection;
use App\Models\Equipment\EquipmentInspectionDetail;
use App\Models\Equipment\EquipmentInspectionTemplate;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MobileInspectionController extends Controller
{
public function show(Request $request, int $id): View
{
$equipment = Equipment::with(['manager', 'subManager'])->findOrFail($id);
$cycle = $request->input('cycle', InspectionCycle::DAILY);
$today = now()->format('Y-m-d');
$period = InspectionCycle::resolvePeriod($cycle, $today);
$activeCycles = EquipmentInspectionTemplate::where('equipment_id', $equipment->id)
->where('is_active', true)
->distinct()
->pluck('inspection_cycle')
->toArray();
$templates = EquipmentInspectionTemplate::where('equipment_id', $equipment->id)
->where('inspection_cycle', $cycle)
->where('is_active', true)
->orderBy('sort_order')
->get();
$inspection = EquipmentInspection::where('equipment_id', $equipment->id)
->where('inspection_cycle', $cycle)
->where('year_month', $period)
->first();
$details = collect();
if ($inspection) {
$details = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
->where('check_date', $today)
->get()
->keyBy('template_item_id');
}
$canInspect = $equipment->canInspect();
return view('mobile.inspection.show', compact(
'equipment',
'cycle',
'today',
'period',
'activeCycles',
'templates',
'details',
'canInspect',
));
}
}

View File

@@ -0,0 +1,322 @@
<?php
namespace App\Http\Controllers\Rd;
use App\Helpers\AiTokenHelper;
use App\Http\Controllers\Controller;
use App\Models\Rd\CmSong;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class CmSongController extends Controller
{
private string $baseUrl;
private string $apiKey;
public function __construct()
{
$this->baseUrl = config('services.gemini.base_url');
$this->apiKey = config('services.gemini.api_key');
}
/**
* 나레이션 목록
*/
public function index(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.cm-song.index'));
}
$songs = CmSong::with('user')
->orderByDesc('created_at')
->paginate(20);
return view('rd.cm-song.index', compact('songs'));
}
/**
* 나레이션 제작 페이지
*/
public function create(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.cm-song.create'));
}
return view('rd.cm-song.create');
}
/**
* 나레이션 상세
*/
public function show(Request $request, int $id): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.cm-song.show', $id));
}
$song = CmSong::with('user')->findOrFail($id);
return view('rd.cm-song.show', compact('song'));
}
/**
* 나레이션 가사 생성 (Gemini API)
*/
public function generateLyrics(Request $request): JsonResponse
{
$request->validate([
'company_name' => 'required|string|max:100',
'industry' => 'required|string|max:200',
'mood' => 'required|string|max:50',
'duration' => 'required|integer|min:10|max:60',
]);
$duration = $request->duration;
$lines = match (true) {
$duration <= 15 => '3~4줄',
$duration <= 30 => '6~8줄',
$duration <= 45 => '10~12줄',
default => '14~16줄',
};
$prompt = "당신은 전문 나레이션 작사가입니다. 다음 정보를 바탕으로 기억에 남는 {$duration}초 분량의 라디오 나레이션 가사를 작성해주세요.
회사명: {$request->company_name}
업종/제품: {$request->industry}
분위기: {$request->mood}
조건:
- {$lines}로 작성 ({$duration}초 분량)
- 운율을 살려서 작성
- 지시문(예: (음악 소리), (밝은 목소리로)) 없이 오직 읽을 수 있는 가사 텍스트만 출력할 것.";
try {
$response = Http::timeout(30)->post(
"{$this->baseUrl}/models/gemini-2.5-flash:generateContent?key={$this->apiKey}",
[
'contents' => [
['parts' => [['text' => $prompt]]],
],
]
);
if (! $response->successful()) {
return response()->json([
'success' => false,
'error' => '가사 생성에 실패했습니다: '.$response->status(),
], 500);
}
$data = $response->json();
// 토큰 사용량 기록
AiTokenHelper::saveGeminiUsage($data, 'gemini-2.5-flash', '나레이션-가사생성');
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
return response()->json([
'success' => true,
'lyrics' => trim($text),
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => '가사 생성 중 오류: '.$e->getMessage(),
], 500);
}
}
/**
* TTS 음성 생성 (Gemini TTS API)
*/
public function generateAudio(Request $request): JsonResponse
{
$request->validate([
'lyrics' => 'required|string|max:2000',
]);
try {
$response = Http::timeout(60)->post(
"{$this->baseUrl}/models/gemini-2.5-flash-preview-tts:generateContent?key={$this->apiKey}",
[
'contents' => [
['parts' => [['text' => $request->lyrics]]],
],
'generationConfig' => [
'responseModalities' => ['AUDIO'],
'speechConfig' => [
'voiceConfig' => [
'prebuiltVoiceConfig' => [
'voiceName' => 'Kore',
],
],
],
],
]
);
if (! $response->successful()) {
return response()->json([
'success' => false,
'error' => '음성 생성에 실패했습니다: '.$response->status(),
], 500);
}
$data = $response->json();
// 토큰 사용량 기록
AiTokenHelper::saveGeminiUsage($data, 'gemini-2.5-flash-preview-tts', '나레이션-TTS');
$inlineData = $data['candidates'][0]['content']['parts'][0]['inlineData'] ?? null;
if (! $inlineData || empty($inlineData['data'])) {
return response()->json([
'success' => false,
'error' => '음성 데이터를 받지 못했습니다.',
], 500);
}
return response()->json([
'success' => true,
'audio_data' => $inlineData['data'],
'mime_type' => $inlineData['mimeType'] ?? 'audio/L16;rate=24000',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => '음성 생성 중 오류: '.$e->getMessage(),
], 500);
}
}
/**
* 나레이션 저장
*/
public function store(Request $request): JsonResponse
{
$request->validate([
'company_name' => 'required|string|max:100',
'industry' => 'required|string|max:200',
'mood' => 'required|string|max:50',
'duration' => 'required|integer|min:10|max:60',
'lyrics' => 'required|string|max:2000',
'audio_data' => 'nullable|string',
'audio_mime_type' => 'nullable|string',
]);
$tenantId = session('selected_tenant_id', 1);
$userId = Auth::id();
$audioPath = null;
// 오디오 데이터가 있으면 WAV 파일로 저장
if ($request->audio_data) {
$mimeType = $request->audio_mime_type ?? 'audio/L16;rate=24000';
$audioBytes = base64_decode($request->audio_data);
if (str_contains($mimeType, 'L16') || str_contains($mimeType, 'pcm')) {
$sampleRate = 24000;
if (preg_match('/rate=(\d+)/', $mimeType, $m)) {
$sampleRate = (int) $m[1];
}
$audioBytes = $this->pcmToWav($audioBytes, $sampleRate);
}
$filename = 'cm-song-'.date('Ymd-His').'-'.uniqid().'.wav';
$dir = "cm-songs/{$tenantId}";
Storage::disk('tenant')->makeDirectory($dir);
Storage::disk('tenant')->put("{$dir}/{$filename}", $audioBytes);
$audioPath = "{$dir}/{$filename}";
}
$song = CmSong::create([
'tenant_id' => $tenantId,
'user_id' => $userId,
'company_name' => $request->company_name,
'industry' => $request->industry,
'lyrics' => $request->lyrics,
'audio_path' => $audioPath,
'options' => [
'mood' => $request->mood,
'duration' => $request->duration,
],
]);
return response()->json([
'success' => true,
'id' => $song->id,
'message' => '나레이션이 저장되었습니다.',
]);
}
/**
* 음성 파일 다운로드
*/
public function download(int $id)
{
$song = CmSong::findOrFail($id);
if (! $song->audio_path || ! Storage::disk('tenant')->exists($song->audio_path)) {
abort(404, '음성 파일이 없습니다.');
}
$filename = "나레이션_{$song->company_name}_".date('Ymd', strtotime($song->created_at)).'.wav';
return Storage::disk('tenant')->download($song->audio_path, $filename);
}
/**
* 나레이션 삭제
*/
public function destroy(int $id): JsonResponse
{
$song = CmSong::findOrFail($id);
if ($song->audio_path && Storage::disk('tenant')->exists($song->audio_path)) {
Storage::disk('tenant')->delete($song->audio_path);
}
$song->delete();
return response()->json([
'success' => true,
'message' => '나레이션이 삭제되었습니다.',
]);
}
/**
* PCM → WAV 변환 (서버사이드)
*/
private function pcmToWav(string $pcmData, int $sampleRate): string
{
$numChannels = 1;
$bitsPerSample = 16;
$byteRate = $sampleRate * $numChannels * $bitsPerSample / 8;
$blockAlign = $numChannels * $bitsPerSample / 8;
$dataSize = strlen($pcmData);
$header = pack('A4VVA4', 'RIFF', 36 + $dataSize, 0x45564157, 'WAVEfmt ');
// 'WAVE' as little-endian is 0x45564157... actually let me write it properly
$header = 'RIFF';
$header .= pack('V', 36 + $dataSize);
$header .= 'WAVE';
$header .= 'fmt ';
$header .= pack('V', 16); // SubChunk1Size
$header .= pack('v', 1); // AudioFormat (PCM)
$header .= pack('v', $numChannels);
$header .= pack('V', $sampleRate);
$header .= pack('V', $byteRate);
$header .= pack('v', $blockAlign);
$header .= pack('v', $bitsPerSample);
$header .= 'data';
$header .= pack('V', $dataSize);
return $header.$pcmData;
}
}

View File

@@ -0,0 +1,592 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\AiTokenHelper;
use App\Models\HR\Employee;
use App\Models\Rd\AiQuotation;
use App\Models\Tenants\Department;
use App\Models\Tenants\Tenant;
use App\Services\Rd\AiQuotationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class RdController extends Controller
{
public function __construct(
private readonly AiQuotationService $quotationService
) {}
/**
* R&D 대시보드
*/
public function index(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.index'));
}
$dashboard = $this->quotationService->getDashboardStats();
$statuses = AiQuotation::getStatuses();
return view('rd.index', compact('dashboard', 'statuses'));
}
/**
* 조직도 관리
*/
public function orgChart(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.org-chart'));
}
$tenantId = session('selected_tenant_id');
// 부서 트리 (parent_id=null이 최상위)
$departments = Department::where('tenant_id', $tenantId)
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get();
// 전체 직원 (활성 상태)
$rawEmployees = Employee::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('employee_status', 'active')
->with(['user', 'department'])
->orderBy('display_name')
->get();
// Blade @json 호환을 위해 미리 배열로 변환
$employees = $rawEmployees->map(function ($e) {
return [
'id' => $e->id,
'user_id' => $e->user_id,
'department_id' => $e->department_id,
'display_name' => $e->display_name ?? $e->user?->name ?? '(이름없음)',
'position_label' => $e->position_label,
];
})->values();
// 회사 정보 (조직도 최상단)
$tenant = Tenant::find($tenantId);
$companyName = $tenant->company_name ?? 'SAM';
$ceoName = $tenant->ceo_name ?? '';
return view('rd.org-chart', compact('departments', 'employees', 'companyName', 'ceoName'));
}
/**
* 조직도 - 직원 부서 배치
*/
public function orgChartAssign(Request $request): JsonResponse
{
$request->validate([
'employee_id' => 'required|integer',
'department_id' => 'required|integer',
]);
$tenantId = session('selected_tenant_id');
$employee = Employee::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('id', $request->employee_id)
->first();
if (! $employee) {
return response()->json(['success' => false, 'message' => '직원을 찾을 수 없습니다.'], 404);
}
$employee->department_id = $request->department_id;
$employee->save();
return response()->json(['success' => true]);
}
/**
* 조직도 - 직원 부서 해제 (미배치로 이동)
*/
public function orgChartUnassign(Request $request): JsonResponse
{
$request->validate([
'employee_id' => 'required|integer',
]);
$tenantId = session('selected_tenant_id');
$employee = Employee::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('id', $request->employee_id)
->first();
if (! $employee) {
return response()->json(['success' => false, 'message' => '직원을 찾을 수 없습니다.'], 404);
}
$employee->department_id = null;
$employee->save();
return response()->json(['success' => true]);
}
/**
* 조직도 - 부서 내 직원 순서/이동 일괄 처리
*/
public function orgChartReorder(Request $request): JsonResponse
{
$request->validate([
'moves' => 'required|array',
'moves.*.employee_id' => 'required|integer',
'moves.*.department_id' => 'nullable|integer',
]);
$tenantId = session('selected_tenant_id');
foreach ($request->moves as $move) {
Employee::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('id', $move['employee_id'])
->update(['department_id' => $move['department_id']]);
}
return response()->json(['success' => true]);
}
/**
* 조직도 - 부서 순서 변경 (드래그 앤 드롭)
*/
public function orgChartReorderDepts(Request $request): JsonResponse
{
$request->validate([
'orders' => 'required|array',
'orders.*.id' => 'required|integer',
'orders.*.parent_id' => 'nullable|integer',
'orders.*.sort_order' => 'required|integer',
]);
$tenantId = session('selected_tenant_id');
foreach ($request->orders as $order) {
Department::where('tenant_id', $tenantId)
->where('id', $order['id'])
->update([
'parent_id' => $order['parent_id'],
'sort_order' => $order['sort_order'],
]);
}
return response()->json(['success' => true]);
}
/**
* 조직도 - 부서 숨기기/표시 토글
*/
public function orgChartToggleHide(Request $request): JsonResponse
{
$request->validate([
'department_id' => 'required|integer',
'hidden' => 'required|boolean',
]);
$tenantId = session('selected_tenant_id');
$dept = Department::where('tenant_id', $tenantId)
->where('id', $request->department_id)
->first();
if (! $dept) {
return response()->json(['success' => false, 'message' => '부서를 찾을 수 없습니다.'], 404);
}
$options = $dept->options ?? [];
$options['orgchart_hidden'] = $request->hidden;
$dept->options = $options;
$dept->save();
return response()->json(['success' => true]);
}
/**
* 중대재해처벌법 실무 점검
*/
public function safetyAudit(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.safety-audit'));
}
return view('rd.safety-audit');
}
/**
* AI 견적 목록
*/
public function quotations(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.index'));
}
$statuses = AiQuotation::getStatuses();
return view('rd.ai-quotation.index', compact('statuses'));
}
/**
* AI 견적 생성 폼
*/
public function createQuotation(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.create'));
}
return view('rd.ai-quotation.create');
}
/**
* AI 견적 문서 (인쇄용 견적서)
*/
public function documentQuotation(Request $request, int $id): View
{
$quotation = $this->quotationService->getById($id);
if (! $quotation || ! $quotation->isCompleted()) {
abort(404, '완료된 견적만 문서로 조회할 수 있습니다.');
}
$template = $request->query('template', 'classic');
$allowed = ['classic', 'modern', 'blue', 'dark', 'colorful'];
if (! in_array($template, $allowed)) {
$template = 'classic';
}
return view('rd.ai-quotation.document', compact('quotation', 'template'));
}
/**
* AI 견적 상세
*/
public function showQuotation(Request $request, int $id): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.show', $id));
}
$quotation = $this->quotationService->getById($id);
if (! $quotation) {
abort(404, 'AI 견적을 찾을 수 없습니다.');
}
return view('rd.ai-quotation.show', compact('quotation'));
}
/**
* AI 견적 편집 (제조 모드)
*/
public function editQuotation(Request $request, int $id): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.edit', $id));
}
$quotation = $this->quotationService->getById($id);
if (! $quotation) {
abort(404, 'AI 견적을 찾을 수 없습니다.');
}
if (! $quotation->isCompleted()) {
abort(403, '완료된 견적만 편집할 수 있습니다.');
}
return view('rd.ai-quotation.edit', compact('quotation'));
}
/**
* 기획디자인 - 플래닝 캔버스
*/
public function planningDesign(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.planning-design'));
}
return view('rd.planning-design.index');
}
/**
* 디자인 인사이트 - UI/UX 연구 도구
*/
public function designInsight(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.design-insight'));
}
return view('rd.design-insight.index');
}
/**
* 사운드 로고 생성기
*/
public function soundLogo(Request $request): View|\Illuminate\Http\Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('rd.sound-logo'));
}
return view('rd.sound-logo.index');
}
/**
* Lyria RealTime 접속용 API 설정 반환
*/
public function soundLogoLyriaConfig(): JsonResponse
{
$apiKey = config('services.gemini.api_key');
if (! $apiKey) {
return response()->json(['success' => false, 'error' => 'API 키가 설정되지 않았습니다.'], 500);
}
return response()->json([
'success' => true,
'api_key' => $apiKey,
'ws_url' => 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateMusic',
'model' => 'models/lyria-realtime-exp',
]);
}
/**
* 사운드 로고 AI 생성 (Gemini API)
*/
public function soundLogoGenerate(Request $request): JsonResponse
{
$request->validate([
'prompt' => 'required|string|max:500',
'category' => 'nullable|string',
'duration' => 'nullable|numeric|min:0.3|max:5',
]);
$apiKey = config('services.gemini.api_key');
$baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta');
$model = config('services.gemini.model', 'gemini-2.5-flash');
if (! $apiKey) {
return response()->json(['success' => false, 'error' => 'Gemini API 키가 설정되지 않았습니다.'], 500);
}
$category = $request->category ?? '기업 시그널';
$duration = $request->duration ?? 1.5;
$prompt = <<<PROMPT
당신은 사운드 디자인 전문가입니다. 사용자의 요청에 맞는 사운드 로고(짧은 시그니처 사운드)를 Web Audio API 음표 시퀀스로 설계해주세요.
## 사용자 요청
- 설명: {$request->prompt}
- 카테고리: {$category}
- 목표 길이: {$duration}초
## 사용 가능한 음표
C3, C#3, D3, D#3, E3, F3, F#3, G3, G#3, A3, A#3, B3,
C4, C#4, D4, D#4, E4, F4, F#4, G4, G#4, A4, A#4, B4,
C5, C#5, D5, D#5, E5, F5, F#5, G5, G#5, A5, A#5, B5, C6
## 음표 타입
- note: 단일 음 (note 필드 필수)
- chord: 화음 (chord 배열 필수, 2~4개 음)
- rest: 쉼표 (duration만 필요)
## 신스 타입
- sine: 부드러움 (기업 로고, 알림에 적합)
- triangle: 따뜻함 (성공, 게임에 적합)
- square: 8bit/디지털 (게임, UI에 적합)
- sawtooth: 날카로움 (록, 긴급 알림에 적합)
## 반드시 아래 JSON 형식으로만 응답하세요
{
"name": "사운드 이름",
"desc": "사운드 설명 (한줄)",
"synth": "sine",
"adsr": { "attack": 10, "decay": 80, "sustain": 0.6, "release": 400 },
"volume": 0.8,
"reverb": 0.3,
"notes": [
{ "type": "note", "note": "C5", "duration": 0.20, "velocity": 0.8 },
{ "type": "rest", "duration": 0.10 },
{ "type": "chord", "chord": ["C4", "E4", "G4"], "duration": 0.50, "velocity": 1.0 }
]
}
## 설계 원칙
- 음표의 duration 합계가 목표 길이({$duration}초)에 근접하도록 설계
- velocity: 0.3~1.0 (음의 강약으로 표현력 추가)
- ADSR: attack(1~500ms), decay(10~1000ms), sustain(0~1.0), release(10~3000ms)
- 카테고리 특성에 맞는 synth와 ADSR 선택
- 음악적으로 조화롭고 기억에 남는 멜로디 설계
- 최소 2개, 최대 12개 음표 사용
- name은 10자 이내, desc는 30자 이내로 간결하게 작성
PROMPT;
try {
$response = Http::timeout(30)->post(
"{$baseUrl}/models/{$model}:generateContent?key={$apiKey}",
[
'contents' => [
['parts' => [['text' => $prompt]]],
],
'generationConfig' => [
'temperature' => 0.7,
'maxOutputTokens' => 4096,
'responseMimeType' => 'application/json',
],
]
);
} catch (\Exception $e) {
Log::error('SoundLogo AI 생성 실패', ['error' => $e->getMessage()]);
return response()->json(['success' => false, 'error' => 'AI 서버 연결 실패'], 500);
}
if (! $response->successful()) {
Log::error('SoundLogo AI API 오류', ['status' => $response->status(), 'body' => $response->body()]);
return response()->json(['success' => false, 'error' => 'AI 생성 실패: '.$response->status()], 500);
}
$data = $response->json();
// 토큰 사용량 기록
AiTokenHelper::saveGeminiUsage($data, $model, '사운드로고-AI생성');
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
// JSON 파싱 (코드블록 제거)
$text = preg_replace('/^```(?:json)?\s*/m', '', $text);
$text = preg_replace('/```\s*$/m', '', $text);
$result = json_decode(trim($text), true);
if (! $result || ! isset($result['notes'])) {
Log::warning('SoundLogo AI 응답 파싱 실패', ['text' => substr($text, 0, 500)]);
return response()->json(['success' => false, 'error' => 'AI 응답을 파싱할 수 없습니다.'], 500);
}
return response()->json(['success' => true, 'data' => $result]);
}
/**
* 사운드 로고 TTS 음성 생성 (Gemini TTS API)
*/
public function soundLogoTts(Request $request): JsonResponse
{
$request->validate([
'text' => 'required|string|max:200',
'voice_name' => 'nullable|string|max:30',
'voice_category' => 'nullable|string|in:female,male,child',
'voice_style' => 'nullable|string|max:100',
'voice_speed' => 'nullable|integer|min:1|max:5',
]);
$apiKey = config('services.gemini.api_key');
$baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta');
if (! $apiKey) {
return response()->json(['success' => false, 'error' => 'Gemini API 키가 설정되지 않았습니다.'], 500);
}
$voiceName = $request->voice_name ?: 'Kore';
$voiceCategory = $request->voice_category ?: 'female';
$voiceStyle = $request->voice_style ?: '';
$voiceSpeed = $request->voice_speed ?: 3;
// 속도 지시문 매핑
$speedDirectives = [
1 => '아주 천천히 또박또박 말해주세요.',
2 => '조금 느린 속도로 말해주세요.',
3 => '',
4 => '조금 빠른 속도로 말해주세요.',
5 => '아주 빠른 속도로 말해주세요.',
];
// TTS 프롬프트 구성 — Director's Note 형식
$ttsText = $request->text;
$notes = [];
// 아이 카테고리: 높은 톤으로 어린이처럼 연기하도록 강한 지시문
if ($voiceCategory === 'child') {
$notes[] = 'Speak as a young child with a high-pitched, innocent voice';
}
if ($voiceStyle) {
$notes[] = $voiceStyle;
}
if (! empty($speedDirectives[$voiceSpeed])) {
$notes[] = $speedDirectives[$voiceSpeed];
}
if (! empty($notes)) {
$direction = implode('. ', $notes);
$ttsText = "[{$direction}]\n\n{$ttsText}";
}
// 짧은 텍스트는 TTS 모델이 텍스트 생성으로 인식할 수 있으므로 발화 컨텍스트 추가
if (mb_strlen($ttsText) < 4) {
$ttsText = "'{$ttsText}' ";
}
try {
$response = Http::timeout(30)->post(
"{$baseUrl}/models/gemini-2.5-flash-preview-tts:generateContent?key={$apiKey}",
[
'contents' => [
['parts' => [['text' => $ttsText]]],
],
'generationConfig' => [
'responseModalities' => ['AUDIO'],
'speechConfig' => [
'voiceConfig' => [
'prebuiltVoiceConfig' => [
'voiceName' => $voiceName,
],
],
],
],
]
);
} catch (\Exception $e) {
Log::error('SoundLogo TTS 생성 실패', ['error' => $e->getMessage()]);
return response()->json(['success' => false, 'error' => 'TTS 서버 연결 실패'], 500);
}
if (! $response->successful()) {
$body = $response->json();
$msg = $body['error']['message'] ?? ('TTS 생성 실패: '.$response->status());
Log::warning('SoundLogo TTS API 에러', ['status' => $response->status(), 'body' => $msg]);
return response()->json(['success' => false, 'error' => $msg], $response->status());
}
$data = $response->json();
// 토큰 사용량 기록
AiTokenHelper::saveGeminiUsage($data, 'gemini-2.5-flash-preview-tts', '사운드로고-TTS');
$inlineData = $data['candidates'][0]['content']['parts'][0]['inlineData'] ?? null;
if (! $inlineData || empty($inlineData['data'])) {
return response()->json(['success' => false, 'error' => '음성 데이터를 받지 못했습니다.'], 500);
}
return response()->json([
'success' => true,
'audio_data' => $inlineData['data'],
'mime_type' => $inlineData['mimeType'] ?? 'audio/L16;rate=24000',
]);
}
}

View File

@@ -0,0 +1,242 @@
<?php
namespace App\Http\Controllers;
use App\Models\Admin\AdminRoadmapPlan;
use App\Services\Roadmap\RoadmapPlanService;
use Illuminate\Support\Str;
use Illuminate\View\View;
class RoadmapController extends Controller
{
/** @deprecated config('roadmap.docs_base')로 대체 */
private const DOCS_BASE = __DIR__.'/../../../../docs';
private const DOCUMENT_REGISTRY = [
[
'category' => 'vision',
'category_label' => '비전 & 전략',
'icon' => 'ri-lightbulb-line',
'color' => 'indigo',
'items' => [
[
'slug' => 'ai-automation-vision',
'title' => 'SAM AI 자동화 비전',
'description' => 'SAM의 장기 비전과 AI 자동화 전략. 영업에서 출고까지 End-to-End 자동화 로드맵.',
'path' => 'system/ai-automation-vision.md',
'date' => '2026-03-02',
'badge' => '설계 확정',
],
[
'slug' => 'scaling-roadmap',
'title' => '10,000 테넌트 스케일링 로드맵',
'description' => '현재 아키텍처 진단부터 5단계 스케일링 계획까지. 세계 수준 엔지니어링 시나리오.',
'path' => 'system/scaling-roadmap.md',
'date' => '2026-02-22',
'badge' => '가상 시나리오',
],
],
],
[
'category' => 'launch',
'category_label' => '프로젝트 런칭',
'icon' => 'ri-rocket-line',
'color' => 'blue',
'items' => [
[
'slug' => 'project-launch-roadmap',
'title' => 'SAM 프로젝트 런칭 로드맵',
'description' => '전체 시스템 구성, MVP 범위, 마일스톤(MS1~MS3), 개발 완료율 현황.',
'path' => 'guides/project-launch-roadmap.md',
'date' => '2025-12-02',
'badge' => '진행중',
],
[
'slug' => 'production-deployment-plan',
'title' => '운영 환경 배포 계획서',
'description' => 'MS3 정식 런칭 배포 계획. 무중단 전환, 롤백, Jenkins CI/CD 자동화.',
'path' => 'plans/production-deployment-plan.md',
'date' => '2026-02-22',
'badge' => '계획 수립',
],
],
],
[
'category' => 'product',
'category_label' => '제품 설계',
'icon' => 'ri-draft-line',
'color' => 'green',
'items' => [
[
'slug' => 'erp-storyboard',
'title' => 'SAM ERP 스토리보드 D1.4',
'description' => '전체 ERP 메뉴 구조와 화면 설계. 대시보드, MES, HR, 전자결재, 회계, 구독 관리.',
'path' => 'plans/SAM_ERP_Storyboard_D1.4.md',
'date' => '2026-01-16',
'badge' => 'D1.4',
],
[
'slug' => 'erp-accounting-storyboard',
'title' => 'SAM ERP 회계관리 스토리보드 D1.6',
'description' => '세금계산서, 계좌 입출금, OCR, 일일 보고서, 건설/생산 대시보드.',
'path' => 'plans/SAM_ERP_회계관리_Storyboard_D1.6.md',
'date' => '2026-02-20',
'badge' => 'D1.6',
],
[
'slug' => 'integrated-master-plan',
'title' => '통합 개선 마스터 플랜',
'description' => '제품코드 추적성 + 검사 단위 구조 통합 개선. 7단계 Phase 로드맵.',
'path' => 'plans/integrated-master-plan.md',
'date' => '2026-02-27',
'badge' => 'Phase 0~3 완료',
],
[
'slug' => 'ai-quotation-engine-plan',
'title' => 'AI 견적서 자동생성 엔진 개발 계획',
'description' => '인터뷰 내용을 AI가 분석하여 SAM 표준 견적서로 자동 변환하는 엔진. Claude API 기반.',
'path' => 'plans/ai-quotation-engine-plan.md',
'date' => '2026-03-02',
'badge' => '기획 초안',
],
],
],
[
'category' => 'system',
'category_label' => '시스템 개요',
'icon' => 'ri-server-line',
'color' => 'gray',
'items' => [
[
'slug' => 'system-overview',
'title' => 'SAM 시스템 개요',
'description' => '프로젝트 아키텍처, 기술 스택, 멀티테넌시, 레거시 마이그레이션 현황.',
'path' => 'system/overview.md',
'date' => '2026-02-27',
'badge' => '최신',
],
],
],
];
public function __construct(
private readonly RoadmapPlanService $planService
) {}
private function getDocsBasePath(): string
{
$path = config('roadmap.docs_base', self::DOCS_BASE);
return realpath($path) ?: $path;
}
public function index(): View
{
$summary = $this->planService->getDashboardSummary();
$statuses = AdminRoadmapPlan::getStatuses();
$categories = AdminRoadmapPlan::getCategories();
$priorities = AdminRoadmapPlan::getPriorities();
$phases = AdminRoadmapPlan::getPhases();
return view('roadmap.index', compact(
'summary', 'statuses', 'categories', 'priorities', 'phases'
));
}
public function plans(): View
{
$statuses = AdminRoadmapPlan::getStatuses();
$categories = AdminRoadmapPlan::getCategories();
$priorities = AdminRoadmapPlan::getPriorities();
$phases = AdminRoadmapPlan::getPhases();
return view('roadmap.plans.index', compact('statuses', 'categories', 'priorities', 'phases'));
}
public function createPlan(): View
{
$statuses = AdminRoadmapPlan::getStatuses();
$categories = AdminRoadmapPlan::getCategories();
$priorities = AdminRoadmapPlan::getPriorities();
$phases = AdminRoadmapPlan::getPhases();
return view('roadmap.plans.create', compact('statuses', 'categories', 'priorities', 'phases'));
}
public function showPlan(int $id): View
{
$plan = $this->planService->getPlanById($id, true);
if (! $plan) {
abort(404, '계획을 찾을 수 없습니다.');
}
$statuses = AdminRoadmapPlan::getStatuses();
$categories = AdminRoadmapPlan::getCategories();
$priorities = AdminRoadmapPlan::getPriorities();
$phases = AdminRoadmapPlan::getPhases();
return view('roadmap.plans.show', compact(
'plan', 'statuses', 'categories', 'priorities', 'phases'
));
}
public function editPlan(int $id): View
{
$plan = $this->planService->getPlanById($id, true);
if (! $plan) {
abort(404, '계획을 찾을 수 없습니다.');
}
$statuses = AdminRoadmapPlan::getStatuses();
$categories = AdminRoadmapPlan::getCategories();
$priorities = AdminRoadmapPlan::getPriorities();
$phases = AdminRoadmapPlan::getPhases();
return view('roadmap.plans.edit', compact(
'plan', 'statuses', 'categories', 'priorities', 'phases'
));
}
public function documents(): View
{
$registry = self::DOCUMENT_REGISTRY;
// 각 문서의 파일 존재 여부 확인
$docsBase = $this->getDocsBasePath();
foreach ($registry as &$group) {
foreach ($group['items'] as &$item) {
$item['exists'] = file_exists($docsBase.'/'.$item['path']);
}
}
return view('roadmap.documents.index', compact('registry'));
}
public function showDocument(string $slug): View
{
$document = null;
foreach (self::DOCUMENT_REGISTRY as $group) {
foreach ($group['items'] as $item) {
if ($item['slug'] === $slug) {
$document = $item;
$document['category_label'] = $group['category_label'];
$document['color'] = $group['color'];
break 2;
}
}
}
if (! $document) {
abort(404, '문서를 찾을 수 없습니다.');
}
$docsBase = $this->getDocsBasePath();
$filePath = $docsBase.'/'.$document['path'];
$content = null;
if (file_exists($filePath)) {
$markdown = file_get_contents($filePath);
$content = Str::markdown($markdown);
}
return view('roadmap.documents.show', compact('document', 'content'));
}
}

View File

@@ -124,4 +124,26 @@ public function process(Request $request, int $id)
return redirect()->route('sales.business-cards.manage')
->with('success', '처리가 완료되었습니다.');
}
/**
* 처리완료 건 삭제 (관리자 전용)
*/
public function destroy(Request $request, int $id)
{
if (! auth()->user()->isAdmin()) {
abort(403, '관리자만 삭제할 수 있습니다.');
}
$this->service->delete($id);
if ($request->expectsJson() || $request->header('Accept') === 'application/json') {
return response()->json([
'success' => true,
'message' => '삭제되었습니다.',
]);
}
return redirect()->route('sales.business-cards.manage')
->with('success', '삭제되었습니다.');
}
}

View File

@@ -42,6 +42,7 @@ public function storeCategory(Request $request): JsonResponse
$validated = $request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'parent_id' => 'nullable|integer|exists:interview_categories,id',
]);
$category = $this->service->createCategory($validated);
@@ -122,7 +123,12 @@ public function storeQuestion(Request $request): JsonResponse
$validated = $request->validate([
'interview_template_id' => 'required|integer|exists:interview_templates,id',
'question_text' => 'required|string|max:500',
'question_type' => 'nullable|string|in:checkbox,text',
'question_type' => 'nullable|string|in:checkbox,text,number,select,multi_select,file_upload,formula_input,table_input,bom_tree,price_table,dimension_diagram',
'options' => 'nullable|array',
'ai_hint' => 'nullable|string',
'expected_format' => 'nullable|string|max:100',
'depends_on' => 'nullable|array',
'domain' => 'nullable|string|max:50',
'is_required' => 'nullable|boolean',
]);
@@ -135,7 +141,7 @@ public function updateQuestion(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'question_text' => 'required|string|max:500',
'question_type' => 'nullable|string|in:checkbox,text',
'question_type' => 'nullable|string|in:checkbox,text,number,select,multi_select,file_upload,formula_input,table_input,bom_tree,price_table,dimension_diagram',
'is_required' => 'nullable|boolean',
]);
@@ -224,4 +230,180 @@ public function completeSession(int $id): JsonResponse
return response()->json($session);
}
// ============================================================
// 프로젝트 API
// ============================================================
public function projects(Request $request): JsonResponse
{
$filters = $request->only(['status', 'search']);
return response()->json($this->service->getProjects($filters));
}
public function showProject(int $id): JsonResponse
{
return response()->json($this->service->getProject($id));
}
public function storeProject(Request $request): JsonResponse
{
$validated = $request->validate([
'company_name' => 'required|string|max:200',
'company_type' => 'nullable|string|max:100',
'contact_person' => 'nullable|string|max:100',
'contact_info' => 'nullable|string|max:200',
'product_categories' => 'nullable|array',
]);
$project = $this->service->createProject($validated);
return response()->json($project, 201);
}
public function updateProject(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'company_name' => 'sometimes|string|max:200',
'company_type' => 'nullable|string|max:100',
'contact_person' => 'nullable|string|max:100',
'contact_info' => 'nullable|string|max:200',
'status' => 'nullable|string|in:draft,interviewing,analyzing,code_generated,deployed',
'product_categories' => 'nullable|array',
'summary' => 'nullable|string',
]);
$project = $this->service->updateProject($id, $validated);
return response()->json($project);
}
public function destroyProject(int $id): JsonResponse
{
$this->service->deleteProject($id);
return response()->json(['message' => '삭제되었습니다.']);
}
public function projectTree(int $id): JsonResponse
{
return response()->json($this->service->getProjectTree($id));
}
public function projectProgress(int $id): JsonResponse
{
$project = $this->service->updateProjectProgress($id);
return response()->json($project);
}
// ============================================================
// 첨부파일 API
// ============================================================
public function attachments(int $projectId): JsonResponse
{
return response()->json($this->service->getAttachments($projectId));
}
public function uploadAttachment(Request $request, int $projectId): JsonResponse
{
$request->validate([
'file' => 'required|file|max:51200',
'file_type' => 'nullable|string|in:excel_template,pdf_quote,sample_bom,price_list,photo,voice,other',
'description' => 'nullable|string|max:500',
]);
$attachment = $this->service->uploadAttachment(
$projectId,
$request->only(['file_type', 'description']),
$request->file('file')
);
return response()->json($attachment, 201);
}
public function destroyAttachment(int $id): JsonResponse
{
$this->service->deleteAttachment($id);
return response()->json(['message' => '삭제되었습니다.']);
}
// ============================================================
// 지식 API
// ============================================================
public function knowledge(Request $request, int $projectId): JsonResponse
{
$filters = $request->only(['domain', 'is_verified', 'min_confidence']);
return response()->json($this->service->getKnowledge($projectId, $filters));
}
public function storeKnowledge(Request $request, int $projectId): JsonResponse
{
$validated = $request->validate([
'domain' => 'required|string|in:product_classification,bom_structure,dimension_formula,component_config,pricing_structure,quantity_formula,conditional_logic,quote_format',
'knowledge_type' => 'required|string|in:fact,rule,formula,mapping,range,table',
'title' => 'required|string|max:300',
'content' => 'required|array',
'source_type' => 'nullable|string|in:interview_answer,voice_recording,document,manual',
'source_id' => 'nullable|integer',
'confidence' => 'nullable|numeric|min:0|max:1',
]);
$knowledge = $this->service->createKnowledge($projectId, $validated);
return response()->json($knowledge, 201);
}
public function updateKnowledge(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'title' => 'sometimes|string|max:300',
'content' => 'sometimes|array',
'confidence' => 'nullable|numeric|min:0|max:1',
]);
$knowledge = $this->service->updateKnowledge($id, $validated);
return response()->json($knowledge);
}
public function verifyKnowledge(int $id): JsonResponse
{
$knowledge = $this->service->verifyKnowledge($id);
return response()->json($knowledge);
}
public function destroyKnowledge(int $id): JsonResponse
{
$this->service->deleteKnowledge($id);
return response()->json(['message' => '삭제되었습니다.']);
}
// ============================================================
// 구조화 답변 저장 API
// ============================================================
public function saveAnswer(Request $request): JsonResponse
{
$validated = $request->validate([
'session_id' => 'required|integer',
'question_id' => 'required|integer',
'is_checked' => 'nullable|boolean',
'answer_text' => 'nullable|string',
'answer_data' => 'nullable|array',
'attachments' => 'nullable|array',
'memo' => 'nullable|string',
]);
$answer = $this->service->saveAnswer($validated);
return response()->json($answer);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Tenants\TenantSetting;
use App\Services\TenantService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -45,7 +46,13 @@ public function edit(int $id): View
abort(404, '테넌트를 찾을 수 없습니다.');
}
return view('tenants.edit', compact('tenant'));
$displayCompanyName = TenantSetting::withoutGlobalScopes()
->where('tenant_id', $id)
->where('setting_group', 'company')
->where('setting_key', 'display_company_name')
->first()?->setting_value ?? '';
return view('tenants.edit', compact('tenant', 'displayCompanyName'));
}
/**

View File

@@ -14,7 +14,7 @@
class TenantSettingController extends Controller
{
/**
* 설정 목록 (재고 설정 페이지)
* 설정 목록 (재고 설정 + 회사 표시명 설정)
*/
public function index(Request $request): View|Response
{
@@ -30,21 +30,25 @@ public function index(Request $request): View|Response
$itemTypeLabels = $tenantId ? CommonCode::getItemTypes($tenantId) : [];
// 테넌트 미선택 시 빈 설정
$stockSettings = collect();
$allSettings = collect();
if ($tenantId) {
$stockSettings = TenantSetting::withoutGlobalScopes()
$allSettings = TenantSetting::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('setting_group', 'stock')
->get()
->keyBy('setting_key');
->get();
}
// 설정값 (저장된 값이 없으면 빈 배열/기본값)
$stockSettings = $allSettings->where('setting_group', 'stock')->keyBy('setting_key');
$companySettings = $allSettings->where('setting_group', 'company')->keyBy('setting_key');
// 재고 설정값
$hasSettings = $stockSettings->isNotEmpty();
$stockItemTypes = $stockSettings->get('stock_item_types')?->setting_value ?? [];
$defaultSafetyStock = $stockSettings->get('default_safety_stock')?->setting_value ?? 10;
$lowStockAlert = $stockSettings->get('low_stock_alert')?->setting_value ?? true;
// 회사 표시명 설정값
$displayCompanyName = $companySettings->get('display_company_name')?->setting_value ?? '';
return view('tenant-settings.index', [
'tenant' => $tenant,
'hasSettings' => $hasSettings,
@@ -52,6 +56,7 @@ public function index(Request $request): View|Response
'stockItemTypes' => $stockItemTypes,
'defaultSafetyStock' => $defaultSafetyStock,
'lowStockAlert' => $lowStockAlert,
'displayCompanyName' => $displayCompanyName,
]);
}
@@ -77,6 +82,7 @@ public function store(Request $request): RedirectResponse
'stock_item_types.*' => 'string|in:'.implode(',', $validItemTypes),
'default_safety_stock' => 'required|integer|min:0|max:9999',
'low_stock_alert' => 'nullable|boolean',
'display_company_name' => 'nullable|string|max:100',
]);
// 재고관리 품목유형 저장
@@ -121,6 +127,20 @@ public function store(Request $request): RedirectResponse
]
);
// 회사 표시명 저장
TenantSetting::withoutGlobalScopes()->updateOrCreate(
[
'tenant_id' => $tenantId,
'setting_group' => 'company',
'setting_key' => 'display_company_name',
],
[
'setting_value' => trim($validated['display_company_name'] ?? ''),
'description' => '문서에 인쇄되는 회사 표시명',
'updated_by' => $userId,
]
);
return redirect()->route('tenant-settings.index')
->with('success', '설정이 저장되었습니다.');
}

View File

@@ -3,11 +3,13 @@
namespace App\Http\Controllers;
use App\Models\Department;
use App\Models\HR\Position;
use App\Models\Role;
use App\Models\Tenants\Tenant;
use App\Services\UserService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class UserController extends Controller
@@ -40,6 +42,10 @@ public function create(): View
$roles = $tenantId ? Role::where('tenant_id', $tenantId)->orderBy('name')->get() : collect();
$departments = $tenantId ? Department::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get() : collect();
// 직급/직책 목록
$ranks = $tenantId ? Position::forTenant($tenantId)->ranks()->where('is_active', true)->ordered()->get() : collect();
$titles = $tenantId ? Position::forTenant($tenantId)->titles()->where('is_active', true)->ordered()->get() : collect();
// 본사 테넌트 여부 확인 (본사: 이메일 인증, 그 외: 비밀번호 직접 입력)
$isHQ = false;
if ($tenantId) {
@@ -47,7 +53,7 @@ public function create(): View
$isHQ = $tenant?->tenant_type === 'HQ';
}
return view('users.create', compact('roles', 'departments', 'isHQ'));
return view('users.create', compact('roles', 'departments', 'isHQ', 'ranks', 'titles'));
}
/**
@@ -76,6 +82,19 @@ public function edit(int $id): View
$userRoleIds = $tenantId ? $user->userRoles()->where('tenant_id', $tenantId)->pluck('role_id')->toArray() : [];
$userDepartmentIds = $tenantId ? $user->departmentUsers()->where('tenant_id', $tenantId)->pluck('department_id')->toArray() : [];
return view('users.edit', compact('user', 'roles', 'departments', 'userRoleIds', 'userDepartmentIds'));
// 직급/직책 목록
$ranks = $tenantId ? Position::forTenant($tenantId)->ranks()->where('is_active', true)->ordered()->get() : collect();
$titles = $tenantId ? Position::forTenant($tenantId)->titles()->where('is_active', true)->ordered()->get() : collect();
// tenant_user_profiles에서 현재 position_key, job_title_key 조회
$profile = $tenantId
? DB::table('tenant_user_profiles')
->where('tenant_id', $tenantId)
->where('user_id', $user->id)
->first(['position_key', 'job_title_key', 'employee_status', 'department_id'])
: null;
return view('users.edit', compact('user', 'roles', 'departments', 'userRoleIds', 'userDepartmentIds',
'ranks', 'titles', 'profile'));
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Rd;
use Illuminate\Foundation\Http\FormRequest;
class StoreAiQuotationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'required|string|max:200',
'input_type' => 'required|in:text,voice,document',
'input_text' => 'required_if:input_type,text|nullable|string',
'ai_provider' => 'nullable|in:gemini,claude',
'quote_mode' => 'nullable|in:module,manufacture',
'product_category' => 'nullable|in:SCREEN,STEEL',
'client_company' => 'nullable|string|max:200',
'client_contact' => 'nullable|string|max:100',
'client_phone' => 'nullable|string|max:50',
'client_email' => 'nullable|email|max:200',
];
}
public function messages(): array
{
return [
'title.required' => '견적 제목을 입력하세요.',
'title.max' => '제목은 200자 이내로 입력하세요.',
'input_type.required' => '입력 유형을 선택하세요.',
'input_text.required_if' => '인터뷰 내용을 입력하세요.',
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Roadmap;
use Illuminate\Foundation\Http\FormRequest;
class StoreMilestoneRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'plan_id' => 'required|integer|exists:admin_roadmap_plans,id',
'title' => 'required|string|max:255',
'description' => 'nullable|string|max:2000',
'due_date' => 'nullable|date',
'assignee_id' => 'nullable|integer|exists:users,id',
'sort_order' => 'nullable|integer',
];
}
public function attributes(): array
{
return [
'plan_id' => '계획',
'title' => '마일스톤 제목',
'description' => '설명',
'due_date' => '예정일',
'assignee_id' => '담당자',
];
}
public function messages(): array
{
return [
'plan_id.required' => '계획을 선택해주세요.',
'plan_id.exists' => '유효하지 않은 계획입니다.',
'title.required' => '마일스톤 제목은 필수입니다.',
'title.max' => '마일스톤 제목은 최대 255자까지 입력 가능합니다.',
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Requests\Roadmap;
use App\Models\Admin\AdminRoadmapPlan;
use Illuminate\Foundation\Http\FormRequest;
class StorePlanRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'required|string|max:200',
'description' => 'nullable|string|max:2000',
'content' => 'nullable|string',
'category' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getCategories())),
'status' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getStatuses())),
'priority' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPriorities())),
'phase' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPhases())),
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'progress' => 'nullable|integer|min:0|max:100',
'color' => 'nullable|string|max:7',
'sort_order' => 'nullable|integer',
];
}
public function attributes(): array
{
return [
'title' => '계획 제목',
'description' => '설명',
'content' => '상세 내용',
'category' => '카테고리',
'status' => '상태',
'priority' => '우선순위',
'phase' => 'Phase',
'start_date' => '시작일',
'end_date' => '종료일',
'progress' => '진행률',
'color' => '색상',
];
}
public function messages(): array
{
return [
'title.required' => '계획 제목은 필수입니다.',
'title.max' => '계획 제목은 최대 200자까지 입력 가능합니다.',
'end_date.after_or_equal' => '종료일은 시작일 이후여야 합니다.',
'progress.min' => '진행률은 0 이상이어야 합니다.',
'progress.max' => '진행률은 100 이하여야 합니다.',
];
}
protected function prepareForValidation(): void
{
if (! $this->has('status')) {
$this->merge(['status' => AdminRoadmapPlan::STATUS_PLANNED]);
}
if (! $this->has('category')) {
$this->merge(['category' => AdminRoadmapPlan::CATEGORY_GENERAL]);
}
if (! $this->has('priority')) {
$this->merge(['priority' => AdminRoadmapPlan::PRIORITY_MEDIUM]);
}
if (! $this->has('phase')) {
$this->merge(['phase' => AdminRoadmapPlan::PHASE_1]);
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Roadmap;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMilestoneRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'description' => 'nullable|string|max:2000',
'due_date' => 'nullable|date',
'assignee_id' => 'nullable|integer|exists:users,id',
'sort_order' => 'nullable|integer',
];
}
public function attributes(): array
{
return [
'title' => '마일스톤 제목',
'description' => '설명',
'due_date' => '예정일',
'assignee_id' => '담당자',
];
}
public function messages(): array
{
return [
'title.required' => '마일스톤 제목은 필수입니다.',
'title.max' => '마일스톤 제목은 최대 255자까지 입력 가능합니다.',
];
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests\Roadmap;
use App\Models\Admin\AdminRoadmapPlan;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePlanRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'required|string|max:200',
'description' => 'nullable|string|max:2000',
'content' => 'nullable|string',
'category' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getCategories())),
'status' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getStatuses())),
'priority' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPriorities())),
'phase' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPhases())),
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'progress' => 'nullable|integer|min:0|max:100',
'color' => 'nullable|string|max:7',
'sort_order' => 'nullable|integer',
];
}
public function attributes(): array
{
return [
'title' => '계획 제목',
'description' => '설명',
'content' => '상세 내용',
'category' => '카테고리',
'status' => '상태',
'priority' => '우선순위',
'phase' => 'Phase',
'start_date' => '시작일',
'end_date' => '종료일',
'progress' => '진행률',
'color' => '색상',
];
}
public function messages(): array
{
return [
'title.required' => '계획 제목은 필수입니다.',
'title.max' => '계획 제목은 최대 200자까지 입력 가능합니다.',
'end_date.after_or_equal' => '종료일은 시작일 이후여야 합니다.',
'progress.min' => '진행률은 0 이상이어야 합니다.',
'progress.max' => '진행률은 100 이하여야 합니다.',
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEquipmentInspectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'equipment_id' => 'required|exists:equipments,id',
'template_item_id' => 'required|exists:equipment_inspection_templates,id',
'check_date' => 'required|date',
];
}
public function attributes(): array
{
return [
'equipment_id' => '설비',
'template_item_id' => '점검항목',
'check_date' => '점검일',
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEquipmentRepairRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'equipment_id' => 'required|exists:equipments,id',
'repair_date' => 'required|date',
'repair_type' => 'required|in:internal,external',
'repair_hours' => 'nullable|numeric|min:0',
'description' => 'nullable|string',
'cost' => 'nullable|numeric|min:0',
'vendor' => 'nullable|string|max:100',
'repaired_by' => 'nullable|exists:users,id',
'memo' => 'nullable|string',
];
}
public function attributes(): array
{
return [
'equipment_id' => '설비',
'repair_date' => '수리일',
'repair_type' => '보전구분',
'repair_hours' => '수리시간',
'cost' => '수리비용',
];
}
public function messages(): array
{
return [
'equipment_id.required' => '설비를 선택해주세요.',
'repair_date.required' => '수리일은 필수입니다.',
'repair_type.required' => '보전구분을 선택해주세요.',
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreEquipmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$tenantId = session('selected_tenant_id', 1);
return [
'equipment_code' => [
'required', 'string', 'max:20',
Rule::unique('equipments', 'equipment_code')
->where('tenant_id', $tenantId),
],
'name' => 'required|string|max:100',
'equipment_type' => 'nullable|string|max:50',
'specification' => 'nullable|string|max:255',
'manufacturer' => 'nullable|string|max:100',
'model_name' => 'nullable|string|max:100',
'serial_no' => 'nullable|string|max:100',
'location' => 'nullable|string|max:100',
'production_line' => 'nullable|string|max:50',
'purchase_date' => 'nullable|date',
'install_date' => 'nullable|date',
'purchase_price' => 'nullable|numeric|min:0',
'useful_life' => 'nullable|integer|min:0',
'status' => 'nullable|in:active,idle,disposed',
'disposed_date' => 'nullable|date',
'manager_id' => 'nullable|exists:users,id',
'sub_manager_id' => 'nullable|exists:users,id',
'photo_path' => 'nullable|string|max:500',
'memo' => 'nullable|string',
'is_active' => 'nullable|boolean',
'sort_order' => 'nullable|integer|min:0',
];
}
public function attributes(): array
{
return [
'equipment_code' => '설비코드',
'name' => '설비명',
'equipment_type' => '설비유형',
'manufacturer' => '제조사',
'purchase_date' => '구입일',
'install_date' => '설치일',
'purchase_price' => '구입가격',
];
}
public function messages(): array
{
return [
'equipment_code.required' => '설비코드는 필수입니다.',
'equipment_code.unique' => '이미 존재하는 설비코드입니다.',
'name.required' => '설비명은 필수입니다.',
];
}
}

View File

@@ -63,6 +63,8 @@ public function rules(): array
'role_ids.*' => 'integer|exists:roles,id',
'department_ids' => 'nullable|array',
'department_ids.*' => 'integer|exists:departments,id',
'position_key' => 'nullable|string|max:64',
'job_title_key' => 'nullable|string|max:64',
];
// 비본사 테넌트: 비밀번호 직접 입력 필수

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEquipmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$tenantId = session('selected_tenant_id', 1);
$id = $this->route('id');
return [
'equipment_code' => [
'required', 'string', 'max:20',
Rule::unique('equipments', 'equipment_code')
->where('tenant_id', $tenantId)
->ignore($id),
],
'name' => 'required|string|max:100',
'equipment_type' => 'nullable|string|max:50',
'specification' => 'nullable|string|max:255',
'manufacturer' => 'nullable|string|max:100',
'model_name' => 'nullable|string|max:100',
'serial_no' => 'nullable|string|max:100',
'location' => 'nullable|string|max:100',
'production_line' => 'nullable|string|max:50',
'purchase_date' => 'nullable|date',
'install_date' => 'nullable|date',
'purchase_price' => 'nullable|numeric|min:0',
'useful_life' => 'nullable|integer|min:0',
'status' => 'nullable|in:active,idle,disposed',
'disposed_date' => 'nullable|date',
'manager_id' => 'nullable|exists:users,id',
'sub_manager_id' => 'nullable|exists:users,id',
'photo_path' => 'nullable|string|max:500',
'memo' => 'nullable|string',
'is_active' => 'nullable|boolean',
'sort_order' => 'nullable|integer|min:0',
];
}
public function attributes(): array
{
return [
'equipment_code' => '설비코드',
'name' => '설비명',
];
}
}

View File

@@ -68,6 +68,10 @@ public function rules(): array
'role_ids.*' => 'integer|exists:roles,id',
'department_ids' => 'nullable|array',
'department_ids.*' => 'integer|exists:departments,id',
'position_key' => 'nullable|string|max:64',
'job_title_key' => 'nullable|string|max:64',
'employee_status' => 'nullable|in:active,leave,resigned',
'department_id' => 'nullable|integer|exists:departments,id',
];
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Models\Admin;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class AdminRoadmapMilestone extends Model
{
use SoftDeletes;
protected $table = 'admin_roadmap_milestones';
protected $fillable = [
'plan_id',
'title',
'description',
'status',
'due_date',
'completed_at',
'assignee_id',
'sort_order',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'plan_id' => 'integer',
'due_date' => 'date',
'completed_at' => 'datetime',
'assignee_id' => 'integer',
'sort_order' => 'integer',
'created_by' => 'integer',
'updated_by' => 'integer',
'deleted_by' => 'integer',
];
public const STATUS_PENDING = 'pending';
public const STATUS_COMPLETED = 'completed';
public static function getStatuses(): array
{
return [
self::STATUS_PENDING => '진행중',
self::STATUS_COMPLETED => '완료',
];
}
public function plan(): BelongsTo
{
return $this->belongsTo(AdminRoadmapPlan::class, 'plan_id');
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function getStatusLabelAttribute(): string
{
return self::getStatuses()[$this->status] ?? $this->status;
}
public function getIsCompletedAttribute(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
public function getDdayAttribute(): ?int
{
if (! $this->due_date) {
return null;
}
return now()->startOfDay()->diffInDays($this->due_date, false);
}
public function getDueStatusAttribute(): ?string
{
if (! $this->due_date || $this->status === self::STATUS_COMPLETED) {
return null;
}
$dday = $this->dday;
if ($dday < 0) {
return 'overdue';
}
if ($dday <= 7) {
return 'due_soon';
}
return 'normal';
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Models\Admin;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class AdminRoadmapPlan extends Model
{
use SoftDeletes;
protected $table = 'admin_roadmap_plans';
protected $fillable = [
'title',
'description',
'content',
'category',
'status',
'priority',
'phase',
'start_date',
'end_date',
'progress',
'color',
'sort_order',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'progress' => 'integer',
'sort_order' => 'integer',
'created_by' => 'integer',
'updated_by' => 'integer',
'deleted_by' => 'integer',
];
// 상태
public const STATUS_PLANNED = 'planned';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
public const STATUS_DELAYED = 'delayed';
public const STATUS_CANCELLED = 'cancelled';
// 카테고리
public const CATEGORY_GENERAL = 'general';
public const CATEGORY_PRODUCT = 'product';
public const CATEGORY_INFRASTRUCTURE = 'infrastructure';
public const CATEGORY_BUSINESS = 'business';
public const CATEGORY_HR = 'hr';
// 우선순위
public const PRIORITY_LOW = 'low';
public const PRIORITY_MEDIUM = 'medium';
public const PRIORITY_HIGH = 'high';
public const PRIORITY_CRITICAL = 'critical';
// Phase
public const PHASE_1 = 'phase_1';
public const PHASE_2 = 'phase_2';
public const PHASE_3 = 'phase_3';
public const PHASE_4 = 'phase_4';
public static function getStatuses(): array
{
return [
self::STATUS_PLANNED => '계획',
self::STATUS_IN_PROGRESS => '진행중',
self::STATUS_COMPLETED => '완료',
self::STATUS_DELAYED => '지연',
self::STATUS_CANCELLED => '취소',
];
}
public static function getCategories(): array
{
return [
self::CATEGORY_GENERAL => '일반',
self::CATEGORY_PRODUCT => '제품',
self::CATEGORY_INFRASTRUCTURE => '인프라',
self::CATEGORY_BUSINESS => '사업',
self::CATEGORY_HR => '인사',
];
}
public static function getPriorities(): array
{
return [
self::PRIORITY_LOW => '낮음',
self::PRIORITY_MEDIUM => '보통',
self::PRIORITY_HIGH => '높음',
self::PRIORITY_CRITICAL => '긴급',
];
}
public static function getPhases(): array
{
return [
self::PHASE_1 => 'Phase 1 — 코어 실증',
self::PHASE_2 => 'Phase 2 — 3~5사 확장',
self::PHASE_3 => 'Phase 3 — SaaS 전환',
self::PHASE_4 => 'Phase 4 — 스케일업',
];
}
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
public function scopeCategory($query, string $category)
{
return $query->where('category', $category);
}
public function scopePhase($query, string $phase)
{
return $query->where('phase', $phase);
}
public function milestones(): HasMany
{
return $this->hasMany(AdminRoadmapMilestone::class, 'plan_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
public function getStatusLabelAttribute(): string
{
return self::getStatuses()[$this->status] ?? $this->status;
}
public function getStatusColorAttribute(): string
{
return match ($this->status) {
self::STATUS_PLANNED => 'bg-gray-100 text-gray-800',
self::STATUS_IN_PROGRESS => 'bg-blue-100 text-blue-800',
self::STATUS_COMPLETED => 'bg-green-100 text-green-800',
self::STATUS_DELAYED => 'bg-red-100 text-red-800',
self::STATUS_CANCELLED => 'bg-yellow-100 text-yellow-800',
default => 'bg-gray-100 text-gray-800',
};
}
public function getCategoryLabelAttribute(): string
{
return self::getCategories()[$this->category] ?? $this->category;
}
public function getPriorityLabelAttribute(): string
{
return self::getPriorities()[$this->priority] ?? $this->priority;
}
public function getPriorityColorAttribute(): string
{
return match ($this->priority) {
self::PRIORITY_LOW => 'bg-gray-100 text-gray-600',
self::PRIORITY_MEDIUM => 'bg-blue-100 text-blue-700',
self::PRIORITY_HIGH => 'bg-orange-100 text-orange-700',
self::PRIORITY_CRITICAL => 'bg-red-100 text-red-700',
default => 'bg-gray-100 text-gray-600',
};
}
public function getPhaseLabelAttribute(): string
{
return self::getPhases()[$this->phase] ?? $this->phase;
}
public function getCalculatedProgressAttribute(): int
{
$total = $this->milestones()->count();
if ($total === 0) {
return $this->progress;
}
$completed = $this->milestones()->where('status', AdminRoadmapMilestone::STATUS_COMPLETED)->count();
return (int) round(($completed / $total) * 100);
}
public function getMilestoneStatsAttribute(): array
{
return [
'total' => $this->milestones()->count(),
'pending' => $this->milestones()->where('status', AdminRoadmapMilestone::STATUS_PENDING)->count(),
'completed' => $this->milestones()->where('status', AdminRoadmapMilestone::STATUS_COMPLETED)->count(),
];
}
public function getPeriodAttribute(): string
{
if ($this->start_date && $this->end_date) {
return $this->start_date->format('Y.m').' ~ '.$this->end_date->format('Y.m');
}
if ($this->start_date) {
return $this->start_date->format('Y.m').' ~';
}
return '-';
}
}

View File

@@ -0,0 +1,300 @@
<?php
namespace App\Models\Approvals;
use App\Models\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Approval extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approvals';
protected $casts = [
'content' => 'array',
'attachments' => 'array',
'drafted_at' => 'datetime',
'completed_at' => 'datetime',
'drafter_read_at' => 'datetime',
'current_step' => 'integer',
'resubmit_count' => 'integer',
'rejection_history' => 'array',
'is_urgent' => 'boolean',
];
protected $fillable = [
'tenant_id',
'document_number',
'form_id',
'line_id',
'title',
'content',
'body',
'status',
'is_urgent',
'drafter_id',
'department_id',
'drafted_at',
'completed_at',
'drafter_read_at',
'current_step',
'resubmit_count',
'rejection_history',
'attachments',
'recall_reason',
'parent_doc_id',
'created_by',
'updated_by',
'deleted_by',
];
protected $attributes = [
'status' => 'draft',
'current_step' => 0,
'resubmit_count' => 0,
'is_urgent' => false,
];
// =========================================================================
// 상태 상수
// =========================================================================
public const STATUS_DRAFT = 'draft';
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_ON_HOLD = 'on_hold';
public const STATUSES = [
self::STATUS_DRAFT,
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_REJECTED,
self::STATUS_CANCELLED,
self::STATUS_ON_HOLD,
];
// =========================================================================
// 관계 정의
// =========================================================================
public function form(): BelongsTo
{
return $this->belongsTo(ApprovalForm::class, 'form_id');
}
public function line(): BelongsTo
{
return $this->belongsTo(ApprovalLine::class, 'line_id');
}
public function drafter(): BelongsTo
{
return $this->belongsTo(User::class, 'drafter_id');
}
public function steps(): HasMany
{
return $this->hasMany(ApprovalStep::class, 'approval_id')->orderBy('step_order');
}
public function approverSteps(): HasMany
{
return $this->hasMany(ApprovalStep::class, 'approval_id')
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
->orderBy('step_order');
}
public function referenceSteps(): HasMany
{
return $this->hasMany(ApprovalStep::class, 'approval_id')
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
->orderBy('step_order');
}
public function parentDocument(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_doc_id');
}
public function childDocuments(): HasMany
{
return $this->hasMany(self::class, 'parent_doc_id');
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeWithStatus($query, string $status)
{
return $query->where('status', $status);
}
public function scopeDraft($query)
{
return $query->where('status', self::STATUS_DRAFT);
}
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
public function scopeApproved($query)
{
return $query->where('status', self::STATUS_APPROVED);
}
public function scopeRejected($query)
{
return $query->where('status', self::STATUS_REJECTED);
}
public function scopeOnHold($query)
{
return $query->where('status', self::STATUS_ON_HOLD);
}
public function scopeCompleted($query)
{
return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]);
}
public function scopeByDrafter($query, int $userId)
{
return $query->where('drafter_id', $userId);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public function isEditable(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]);
}
public function isSubmittable(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]);
}
public function isActionable(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isHoldable(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isHoldReleasable(): bool
{
return $this->status === self::STATUS_ON_HOLD;
}
public function isCancellable(): bool
{
return in_array($this->status, [self::STATUS_PENDING, self::STATUS_ON_HOLD]);
}
public function isCopyable(): bool
{
return in_array($this->status, [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]);
}
public function isDeletable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
public function isDeletableBy(?User $user = null): bool
{
if (! $user) {
return $this->isDeletable();
}
if ($user->isAdmin()) {
return true;
}
return $this->status === self::STATUS_DRAFT
&& $this->drafter_id === $user->id;
}
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
self::STATUS_DRAFT => '임시저장',
self::STATUS_PENDING => '진행',
self::STATUS_APPROVED => '완료',
self::STATUS_REJECTED => '반려',
self::STATUS_CANCELLED => '회수',
self::STATUS_ON_HOLD => '보류',
default => $this->status,
};
}
public function getStatusColorAttribute(): string
{
return match ($this->status) {
self::STATUS_DRAFT => 'gray',
self::STATUS_PENDING => 'blue',
self::STATUS_APPROVED => 'green',
self::STATUS_REJECTED => 'red',
self::STATUS_CANCELLED => 'yellow',
self::STATUS_ON_HOLD => 'amber',
default => 'gray',
};
}
public function getCurrentApproverStep(): ?ApprovalStep
{
return $this->steps()
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
->where('status', ApprovalStep::STATUS_PENDING)
->orderBy('step_order')
->first();
}
public function isCurrentApprover(int $userId): bool
{
$currentStep = $this->getCurrentApproverStep();
return $currentStep && $currentStep->approver_id === $userId;
}
public function isReferee(int $userId): bool
{
return $this->referenceSteps()
->where('approver_id', $userId)
->exists();
}
public function getProgressAttribute(): array
{
$totalSteps = $this->approverSteps()->count();
$completedSteps = $this->approverSteps()
->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED])
->count();
return [
'total' => $totalSteps,
'completed' => $completedSteps,
'percentage' => $totalSteps > 0 ? round(($completedSteps / $totalSteps) * 100) : 0,
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models\Approvals;
use App\Models\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ApprovalDelegation extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approval_delegations';
protected $casts = [
'form_ids' => 'array',
'start_date' => 'date',
'end_date' => 'date',
'notify_delegator' => 'boolean',
'is_active' => 'boolean',
];
protected $fillable = [
'tenant_id',
'delegator_id',
'delegate_id',
'start_date',
'end_date',
'form_ids',
'notify_delegator',
'is_active',
'reason',
'created_by',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function delegator(): BelongsTo
{
return $this->belongsTo(User::class, 'delegator_id');
}
public function delegate(): BelongsTo
{
return $this->belongsTo(User::class, 'delegate_id');
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeForDelegator($query, int $userId)
{
return $query->where('delegator_id', $userId);
}
public function scopeCurrentlyActive($query)
{
$today = now()->toDateString();
return $query->active()
->where('start_date', '<=', $today)
->where('end_date', '>=', $today);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Models\Approvals;
use App\Models\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ApprovalForm extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approval_forms';
protected $casts = [
'template' => 'array',
'is_active' => 'boolean',
];
protected $fillable = [
'tenant_id',
'name',
'code',
'category',
'template',
'body_template',
'is_active',
'created_by',
'updated_by',
'deleted_by',
];
// =========================================================================
// 카테고리 상수
// =========================================================================
public const CATEGORY_REQUEST = 'request';
public const CATEGORY_EXPENSE = 'expense';
public const CATEGORY_EXPENSE_ESTIMATE = 'expense_estimate';
public const CATEGORIES = [
self::CATEGORY_REQUEST,
self::CATEGORY_EXPENSE,
self::CATEGORY_EXPENSE_ESTIMATE,
];
// =========================================================================
// 관계 정의
// =========================================================================
public function approvals(): HasMany
{
return $this->hasMany(Approval::class, 'form_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeCategory($query, string $category)
{
return $query->where('category', $category);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public function getCategoryLabelAttribute(): string
{
return match ($this->category) {
self::CATEGORY_REQUEST => '품의서',
self::CATEGORY_EXPENSE => '지출결의서',
self::CATEGORY_EXPENSE_ESTIMATE => '지출 예상 내역서',
default => $this->category ?? '',
};
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Models\Approvals;
use App\Models\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ApprovalLine extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approval_lines';
protected $casts = [
'steps' => 'array',
'is_default' => 'boolean',
];
protected $fillable = [
'tenant_id',
'name',
'steps',
'is_default',
'created_by',
'updated_by',
'deleted_by',
];
// =========================================================================
// 단계 유형 상수
// =========================================================================
public const STEP_TYPE_APPROVAL = 'approval';
public const STEP_TYPE_AGREEMENT = 'agreement';
public const STEP_TYPE_REFERENCE = 'reference';
public const STEP_TYPES = [
self::STEP_TYPE_APPROVAL,
self::STEP_TYPE_AGREEMENT,
self::STEP_TYPE_REFERENCE,
];
// =========================================================================
// 관계 정의
// =========================================================================
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeDefault($query)
{
return $query->where('is_default', true);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public function getStepCountAttribute(): int
{
return count($this->steps ?? []);
}
public function getApproverIdsAttribute(): array
{
return collect($this->steps ?? [])
->pluck('user_id')
->filter()
->values()
->toArray();
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Models\Approvals;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ApprovalStep extends Model
{
protected $table = 'approval_steps';
protected $casts = [
'step_order' => 'integer',
'parallel_group' => 'integer',
'acted_at' => 'datetime',
'is_read' => 'boolean',
'read_at' => 'datetime',
];
protected $fillable = [
'approval_id',
'step_order',
'step_type',
'parallel_group',
'approver_id',
'acted_by',
'approver_name',
'approver_department',
'approver_position',
'status',
'approval_type',
'comment',
'acted_at',
'is_read',
'read_at',
];
protected $attributes = [
'status' => 'pending',
'is_read' => false,
];
// =========================================================================
// 상태 상수
// =========================================================================
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
public const STATUS_SKIPPED = 'skipped';
public const STATUS_ON_HOLD = 'on_hold';
public const STATUSES = [
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_REJECTED,
self::STATUS_SKIPPED,
self::STATUS_ON_HOLD,
];
// =========================================================================
// 관계 정의
// =========================================================================
public function approval(): BelongsTo
{
return $this->belongsTo(Approval::class, 'approval_id');
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approver_id');
}
public function actedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'acted_by');
}
// =========================================================================
// 스코프
// =========================================================================
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
public function scopeApproved($query)
{
return $query->where('status', self::STATUS_APPROVED);
}
public function scopeByApprover($query, int $userId)
{
return $query->where('approver_id', $userId);
}
public function scopeApprovalOnly($query)
{
return $query->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
}
public function scopeReferenceOnly($query)
{
return $query->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public function isActionable(): bool
{
return $this->status === self::STATUS_PENDING
&& in_array($this->step_type, [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
}
public function isReference(): bool
{
return $this->step_type === ApprovalLine::STEP_TYPE_REFERENCE;
}
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
self::STATUS_PENDING => '대기',
self::STATUS_APPROVED => '승인',
self::STATUS_REJECTED => '반려',
self::STATUS_SKIPPED => '건너뜀',
self::STATUS_ON_HOLD => '보류',
default => $this->status,
};
}
public function getStepTypeLabelAttribute(): string
{
return match ($this->step_type) {
ApprovalLine::STEP_TYPE_APPROVAL => '결재',
ApprovalLine::STEP_TYPE_AGREEMENT => '합의',
ApprovalLine::STEP_TYPE_REFERENCE => '참조',
default => $this->step_type,
};
}
}

View File

@@ -49,19 +49,40 @@ public function tenant(): BelongsTo
return $this->belongsTo(Tenant::class);
}
/**
* 적요(summary)에서 상대계좌예금주명(cast/remark2) 중복 제거
* 바로빌 API 응답에서 TransRemark1에 TransRemark2가 포함되는 경우 정리
*/
public static function cleanSummary(string $summary, string $remark): string
{
if (empty($remark) || empty($summary) || ! str_contains($summary, $remark)) {
return $summary;
}
$result = rtrim($summary);
while (str_ends_with($result, $remark) && strlen($result) > strlen($remark)) {
$result = rtrim(substr($result, 0, -strlen($remark)));
}
return $result;
}
/**
* 거래 고유 키 생성 (매칭용)
* 숫자는 정수로 변환하여 형식 통일
*/
public function getUniqueKeyAttribute(): string
{
$cleanSummary = self::cleanSummary($this->summary ?? '', $this->cast ?? '');
return implode('|', [
$this->bank_account_num,
$this->trans_dt,
(int) $this->deposit,
(int) $this->withdraw,
(int) $this->balance,
$this->summary ?? '',
$cleanSummary,
]);
}

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