공지사항 메뉴 수정
This commit is contained in:
Vendored
+8
-1
@@ -4,6 +4,11 @@ pipeline {
|
|||||||
choice(name: 'SERVICE_NAME', choices: ['all', 'frontend', 'backend', 'docker-only'], description: '배포 대상')
|
choice(name: 'SERVICE_NAME', choices: ['all', 'frontend', 'backend', 'docker-only'], description: '배포 대상')
|
||||||
booleanParam(name: 'DEPLOY_APPROVED', defaultValue: true, description: '배포 승인')
|
booleanParam(name: 'DEPLOY_APPROVED', defaultValue: true, description: '배포 승인')
|
||||||
}
|
}
|
||||||
|
environment {
|
||||||
|
GIT_DIR = '/Volumes/ADATA/git/BizCare.git'
|
||||||
|
WORK_TREE = '/Users/aidev/apps/BizCare'
|
||||||
|
PATH = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${env.PATH}"
|
||||||
|
}
|
||||||
stages {
|
stages {
|
||||||
stage('Approve') {
|
stage('Approve') {
|
||||||
steps {
|
steps {
|
||||||
@@ -18,9 +23,11 @@ pipeline {
|
|||||||
steps {
|
steps {
|
||||||
sh '''
|
sh '''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:$PATH"
|
|
||||||
echo "=== BizCare Jenkins Build #${BUILD_NUMBER} ==="
|
echo "=== BizCare Jenkins Build #${BUILD_NUMBER} ==="
|
||||||
echo "시각: $(date)"
|
echo "시각: $(date)"
|
||||||
|
echo "GIT_DIR=${GIT_DIR}"
|
||||||
|
echo "WORK_TREE=${WORK_TREE}"
|
||||||
|
export GIT_DIR WORK_TREE
|
||||||
SERVICE_NAME=${SERVICE_NAME} /Volumes/ADATA/git/deploy-bizcare.sh
|
SERVICE_NAME=${SERVICE_NAME} /Volumes/ADATA/git/deploy-bizcare.sh
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2373,6 +2373,153 @@ class CareHomesController {
|
|||||||
return (asc ? c : c.reversed()).thenComparing(HomesBbs::getId, Comparator.reverseOrder());
|
return (asc ? c : c.reversed()).thenComparing(HomesBbs::getId, Comparator.reverseOrder());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 로그인 후 홈 팝업용 — isPopup=1 이고 기간 유효한 공지 (최근 10건) */
|
||||||
|
@GetMapping("/announces/popups") ApiResponse announcePopups(@AuthenticationPrincipal JwtUser u) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
List<Map<String, Object>> list = listAll(HomesAnnounce.class, u).stream()
|
||||||
|
.filter(a -> a.getIsPopup() != null && a.getIsPopup() == 1)
|
||||||
|
.filter(a -> a.getPopupStart() == null || !a.getPopupStart().isAfter(now))
|
||||||
|
.filter(a -> a.getPopupEnd() == null || !a.getPopupEnd().isBefore(now))
|
||||||
|
.sorted(Comparator.comparing(HomesAnnounce::getId, Comparator.nullsLast(Long::compareTo)).reversed())
|
||||||
|
.limit(10)
|
||||||
|
.map(this::announceMap)
|
||||||
|
.toList();
|
||||||
|
return ApiResponse.ok(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/announces") ApiResponse announces(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
|
||||||
|
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) {
|
||||||
|
String searchType = blankToNull(f.get("searchType"));
|
||||||
|
String q = qOf(f);
|
||||||
|
List<HomesAnnounce> filtered = listAll(HomesAnnounce.class, u).stream().filter(a -> {
|
||||||
|
if (q == null) return true;
|
||||||
|
String hay = switch (searchType == null ? "제목" : searchType) {
|
||||||
|
case "직원", "작성자" -> nullToEmpty(a.getAuthorName());
|
||||||
|
case "내용" -> nullToEmpty(a.getContents());
|
||||||
|
default -> nullToEmpty(a.getTitle());
|
||||||
|
};
|
||||||
|
return hay.toLowerCase().contains(q.toLowerCase());
|
||||||
|
}).sorted(announceComparator(f.get("sort"))).toList();
|
||||||
|
int from = Math.max(0, page) * Math.max(1, size);
|
||||||
|
List<Map<String, Object>> list = new ArrayList<>();
|
||||||
|
int i = 0;
|
||||||
|
for (HomesAnnounce a : filtered.stream().skip(from).limit(Math.min(Math.max(size, 1), 200)).toList()) {
|
||||||
|
Map<String, Object> row = announceMap(a);
|
||||||
|
row.put("no", filtered.size() - from - i);
|
||||||
|
list.add(row);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("total", filtered.size());
|
||||||
|
data.put("list", list);
|
||||||
|
return ApiResponse.ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/announces/{id}") @Transactional ApiResponse announceGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
|
||||||
|
HomesAnnounce a = crud.get(HomesAnnounce.class, id, u);
|
||||||
|
a.setViews((a.getViews() == null ? 0 : a.getViews()) + 1);
|
||||||
|
return ApiResponse.ok(announceMap(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/announces") @Transactional ApiResponse announcePost(@RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
|
||||||
|
d.putIfAbsent("authorName", u.getUsername());
|
||||||
|
d.putIfAbsent("views", 0);
|
||||||
|
applyAnnouncePopupFields(d);
|
||||||
|
HomesAnnounce a = crud.create(HomesAnnounce.class, d, u);
|
||||||
|
writeLog(u, "ANNOUNCE", "공지사항-등록", "공지 [" + nullToEmpty(a.getTitle()) + "] 등록하였습니다.");
|
||||||
|
return ApiResponse.ok(announceMap(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/announces/{id}") @Transactional ApiResponse announcePut(@PathVariable Long id, @RequestBody Map<String, Object> d, @AuthenticationPrincipal JwtUser u) {
|
||||||
|
applyAnnouncePopupFields(d);
|
||||||
|
HomesAnnounce a = crud.update(HomesAnnounce.class, id, d, u);
|
||||||
|
writeLog(u, "ANNOUNCE", "공지사항-수정", "공지 [" + nullToEmpty(a.getTitle()) + "] 수정하였습니다.");
|
||||||
|
return ApiResponse.ok(announceMap(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/announces/{id}") @Transactional ApiResponse announceDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
|
||||||
|
HomesAnnounce a = crud.get(HomesAnnounce.class, id, u);
|
||||||
|
writeLog(u, "ANNOUNCE", "공지사항-삭제", "공지 [" + nullToEmpty(a.getTitle()) + "] 삭제하였습니다.");
|
||||||
|
crud.delete(HomesAnnounce.class, id, u);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 ON/OFF 토글 */
|
||||||
|
@PostMapping("/announces/{id}/popup") @Transactional ApiResponse announceTogglePopup(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
|
||||||
|
HomesAnnounce a = crud.get(HomesAnnounce.class, id, u);
|
||||||
|
boolean on = a.getIsPopup() != null && a.getIsPopup() == 1;
|
||||||
|
a.setIsPopup(on ? 0 : 1);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
if (!on) {
|
||||||
|
if (a.getPopupStart() == null) a.setPopupStart(now);
|
||||||
|
if (a.getPopupEnd() == null) a.setPopupEnd(now.plusDays(7));
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(announceMap(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyAnnouncePopupFields(Map<String, Object> d) {
|
||||||
|
Object popup = d.get("isPopup");
|
||||||
|
boolean on = popup instanceof Number n ? n.intValue() == 1
|
||||||
|
: popup instanceof Boolean b && b
|
||||||
|
|| "1".equals(String.valueOf(popup)) || "true".equalsIgnoreCase(String.valueOf(popup));
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
d.put("isPopup", on ? 1 : 0);
|
||||||
|
if (on) {
|
||||||
|
LocalDateTime start = parseDateTime(d.get("popupStart"));
|
||||||
|
LocalDateTime end = parseDateTime(d.get("popupEnd"));
|
||||||
|
d.put("popupStart", start != null ? start : now);
|
||||||
|
d.put("popupEnd", end != null ? end : now.plusDays(7));
|
||||||
|
} else {
|
||||||
|
d.put("popupStart", now);
|
||||||
|
d.put("popupEnd", now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime parseDateTime(Object v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
String s = String.valueOf(v).trim().replace(' ', 'T');
|
||||||
|
if (s.isEmpty() || "null".equals(s)) return null;
|
||||||
|
try {
|
||||||
|
if (s.length() == 10) return LocalDate.parse(s).atStartOfDay();
|
||||||
|
if (s.length() == 16) return LocalDateTime.parse(s + ":00");
|
||||||
|
return LocalDateTime.parse(s.length() > 19 ? s.substring(0, 19) : s);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Comparator<HomesAnnounce> announceComparator(String sort) {
|
||||||
|
String field = sort == null || sort.isBlank() ? "id,desc" : sort;
|
||||||
|
boolean asc = field.endsWith(",asc");
|
||||||
|
String key = field.split(",")[0];
|
||||||
|
Comparator<HomesAnnounce> c = switch (key) {
|
||||||
|
case "views", "조회" -> Comparator.comparing(a -> a.getViews() == null ? 0 : a.getViews());
|
||||||
|
case "authorName", "직원", "작성자" -> Comparator.comparing(HomesAnnounce::getAuthorName, Comparator.nullsLast(String::compareTo));
|
||||||
|
case "createdDate", "createdAt", "등록일" -> Comparator.comparing(HomesAnnounce::getCreatedAt, Comparator.nullsLast(Instant::compareTo));
|
||||||
|
case "title", "제목" -> Comparator.comparing(HomesAnnounce::getTitle, Comparator.nullsLast(String::compareTo));
|
||||||
|
case "isPopup", "팝업" -> Comparator.comparing(a -> a.getIsPopup() == null ? 0 : a.getIsPopup());
|
||||||
|
default -> Comparator.comparing(HomesAnnounce::getId, Comparator.nullsLast(Long::compareTo));
|
||||||
|
};
|
||||||
|
return (asc ? c : c.reversed()).thenComparing(HomesAnnounce::getId, Comparator.reverseOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> announceMap(HomesAnnounce a) {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", a.getId());
|
||||||
|
m.put("title", a.getTitle());
|
||||||
|
m.put("authorName", a.getAuthorName());
|
||||||
|
m.put("contents", a.getContents());
|
||||||
|
m.put("content", a.getContents());
|
||||||
|
m.put("views", a.getViews());
|
||||||
|
m.put("isPopup", a.getIsPopup() == null ? 0 : a.getIsPopup());
|
||||||
|
m.put("popupStart", a.getPopupStart() == null ? null
|
||||||
|
: a.getPopupStart().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")));
|
||||||
|
m.put("popupEnd", a.getPopupEnd() == null ? null
|
||||||
|
: a.getPopupEnd().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")));
|
||||||
|
m.put("createdDate", a.getCreatedAt() == null ? null : LocalDate.ofInstant(a.getCreatedAt(), ZoneId.of("Asia/Seoul")).toString());
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/logs") ApiResponse logs(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
|
@GetMapping("/logs") ApiResponse logs(@AuthenticationPrincipal JwtUser u, @RequestParam Map<String, String> f,
|
||||||
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) {
|
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) {
|
||||||
String domain = blankToNull(f.get("domain"));
|
String domain = blankToNull(f.get("domain"));
|
||||||
|
|||||||
@@ -2343,6 +2343,20 @@ class DataInitializer implements CommandLineRunner {
|
|||||||
em.remove(b);
|
em.remove(b);
|
||||||
}
|
}
|
||||||
em.flush();
|
em.flush();
|
||||||
|
// 사내 공지사항 샘플 (팝업 ON → 로그인 후 홈에서 표시)
|
||||||
|
if (em.createQuery("select count(a) from HomesAnnounce a where a.groupId='demo'", Long.class).getSingleResult() == 0) {
|
||||||
|
HomesAnnounce a = new HomesAnnounce();
|
||||||
|
a.setGroupId("demo");
|
||||||
|
a.setTitle("BizCare 공지사항 안내");
|
||||||
|
a.setAuthorName("김대표");
|
||||||
|
a.setContents("팝업이 ON인 공지사항은 로그인 후 초기화면에 표시됩니다.\n공지사항 메뉴에서 등록·팝업 기간을 설정하세요.");
|
||||||
|
a.setViews(0);
|
||||||
|
a.setIsPopup(1);
|
||||||
|
a.setPopupStart(LocalDateTime.now().minusDays(1));
|
||||||
|
a.setPopupEnd(LocalDateTime.now().plusMonths(1));
|
||||||
|
em.persist(a);
|
||||||
|
em.flush();
|
||||||
|
}
|
||||||
if (em.createQuery("select count(r) from HomesReception r where r.groupId='demo'", Long.class).getSingleResult() == 0) {
|
if (em.createQuery("select count(r) from HomesReception r where r.groupId='demo'", Long.class).getSingleResult() == 0) {
|
||||||
Object[][] rows = {
|
Object[][] rows = {
|
||||||
{LocalDateTime.of(2026, 7, 10, 9, 12), "김상담", "강남점", "KT", "상품권", "인터넷", "기가인터넷 1G", "홍*동", "010-1111-2222", "서울", "f01", "SAMPLE-00001", "서울입고", "박처리", "접수", "현금", "100000", "h01", null, null, "", "b01", "36개월", "02-1234-5678"},
|
{LocalDateTime.of(2026, 7, 10, 9, 12), "김상담", "강남점", "KT", "상품권", "인터넷", "기가인터넷 1G", "홍*동", "010-1111-2222", "서울", "f01", "SAMPLE-00001", "서울입고", "박처리", "접수", "현금", "100000", "h01", null, null, "", "b01", "36개월", "02-1234-5678"},
|
||||||
|
|||||||
@@ -413,6 +413,18 @@ class HomesBbs extends BaseEntity {
|
|||||||
Integer views;
|
Integer views;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 사내 공지사항 (billing_imeere posts / board_id=1 대응) — 팝업 ON 시 로그인 후 홈에서 표시 */
|
||||||
|
@Entity @Table(name="homes_announces") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||||
|
class HomesAnnounce extends BaseEntity {
|
||||||
|
String title, authorName;
|
||||||
|
@Column(columnDefinition="TEXT") String contents;
|
||||||
|
Integer views;
|
||||||
|
/** 1=팝업 ON, 0=OFF */
|
||||||
|
Integer isPopup;
|
||||||
|
LocalDateTime popupStart;
|
||||||
|
LocalDateTime popupEnd;
|
||||||
|
}
|
||||||
|
|
||||||
/** 접수관리 — billing_react orders 목록 필드를 Care Homes 멀티테넌트에 맞게 정리 */
|
/** 접수관리 — billing_react orders 목록 필드를 Care Homes 멀티테넌트에 맞게 정리 */
|
||||||
@Entity @Table(name="homes_receptions") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
@Entity @Table(name="homes_receptions") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||||
class HomesReception extends BaseEntity {
|
class HomesReception extends BaseEntity {
|
||||||
|
|||||||
+28
-3
@@ -5,18 +5,20 @@
|
|||||||
| 트랙 | 트리거 | 동작 |
|
| 트랙 | 트리거 | 동작 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| Track A | `git push origin main` | `post-receive` → `deploy-bizcare.sh` |
|
| Track A | `git push origin main` | `post-receive` → `deploy-bizcare.sh` |
|
||||||
| Track B | Jenkins `BizCare-Pipeline` | `deploy-bizcare.sh` |
|
| Track B | Jenkins `BizCare-Pipeline` (구 `BillCare-Pipeline` 호환) | `deploy-bizcare.sh` |
|
||||||
|
|
||||||
## 경로
|
## 경로
|
||||||
|
|
||||||
| 항목 | 경로 |
|
| 항목 | 경로 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| bare repo | `/Volumes/ADATA/git/BizCare.git` (또는 기존 `BillCare.git`) |
|
| bare repo (정본) | `/Volumes/ADATA/git/BizCare.git` |
|
||||||
| work-tree | `/Users/aidev/apps/BizCare` (또는 기존 `BillCare`) |
|
| bare repo (구) | `/Volumes/ADATA/git/BillCare.git` → BizCare 배포로 위임 |
|
||||||
|
| work-tree | `/Users/aidev/apps/BizCare` |
|
||||||
| 배포 스크립트 | `/Volumes/ADATA/git/deploy-bizcare.sh` |
|
| 배포 스크립트 | `/Volumes/ADATA/git/deploy-bizcare.sh` |
|
||||||
| 로그 | `/Volumes/ADATA/logs/deploy/bizcare-deploy.log` |
|
| 로그 | `/Volumes/ADATA/logs/deploy/bizcare-deploy.log` |
|
||||||
| HTTPS (정본) | https://bizcare.exdev.co.kr |
|
| HTTPS (정본) | https://bizcare.exdev.co.kr |
|
||||||
| HTTPS (구) | https://billcare.exdev.co.kr → bizcare 로 301 |
|
| HTTPS (구) | https://billcare.exdev.co.kr → bizcare 로 301 |
|
||||||
|
| Jenkins | http://127.0.0.1:8090/ — Job `BizCare-Pipeline` |
|
||||||
|
|
||||||
## 호스트 포트
|
## 호스트 포트
|
||||||
|
|
||||||
@@ -26,6 +28,14 @@
|
|||||||
| backend (직접 헬스체크) | **18082** |
|
| backend (직접 헬스체크) | **18082** |
|
||||||
| mysql | **3308** |
|
| mysql | **3308** |
|
||||||
|
|
||||||
|
## 로컬 remote (필수)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/macbook/dev/billCare
|
||||||
|
git remote set-url origin ssh://aidev@stock.exdev.co.kr/Volumes/ADATA/git/BizCare.git
|
||||||
|
git remote -v # …/BizCare.git 이어야 함
|
||||||
|
```
|
||||||
|
|
||||||
## 로컬 push
|
## 로컬 push
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -33,6 +43,21 @@ cd /Users/macbook/dev/billCare
|
|||||||
git push origin main
|
git push origin main
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`post-receive` → `deploy-bizcare.sh` → `/Users/aidev/apps/BizCare` + `bizcare-*` 컨테이너.
|
||||||
|
|
||||||
|
## Jenkins (Track B)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 서버에서 Job 설치/갱신 (최초 1회 또는 경로 변경 시)
|
||||||
|
bash /Users/aidev/apps/BizCare/scripts/install-jenkins-bizcare-job.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- 정본 Job: **BizCare-Pipeline** (SCM: `file:///Volumes/ADATA/git/BizCare.git`)
|
||||||
|
- 구 Job: **BillCare-Pipeline** 도 동일하게 BizCare 경로로 배포
|
||||||
|
- 배포 명령: `GIT_DIR`/`WORK_TREE` 지정 후 `/Volumes/ADATA/git/deploy-bizcare.sh`
|
||||||
|
|
||||||
|
일회 git 전환: `scripts/migrate-billcare-to-bizcare.sh`
|
||||||
|
|
||||||
## HTTPS / TLS — bizcare.exdev.co.kr 활성화
|
## HTTPS / TLS — bizcare.exdev.co.kr 활성화
|
||||||
|
|
||||||
### 1) DNS
|
### 1) DNS
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import SettingUsageReportPage from './pages/care/SettingUsageReportPage'
|
|||||||
import CsNoticePage from './pages/care/CsNoticePage'
|
import CsNoticePage from './pages/care/CsNoticePage'
|
||||||
import CsQuestionPage from './pages/care/CsQuestionPage'
|
import CsQuestionPage from './pages/care/CsQuestionPage'
|
||||||
import BbsPage from './pages/care/BbsPage'
|
import BbsPage from './pages/care/BbsPage'
|
||||||
|
import AnnouncePage from './pages/care/AnnouncePage'
|
||||||
import SitemapPage from './pages/care/SitemapPage'
|
import SitemapPage from './pages/care/SitemapPage'
|
||||||
|
|
||||||
function ProtectedRoute() {
|
function ProtectedRoute() {
|
||||||
@@ -181,6 +182,7 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="cs/notice" element={<CsNoticePage />} />
|
<Route path="cs/notice" element={<CsNoticePage />} />
|
||||||
<Route path="cs/question" element={<CsQuestionPage />} />
|
<Route path="cs/question" element={<CsQuestionPage />} />
|
||||||
|
<Route path="bbs/notice" element={<AnnouncePage />} />
|
||||||
<Route path="bbs/bbs" element={<BbsPage />} />
|
<Route path="bbs/bbs" element={<BbsPage />} />
|
||||||
|
|
||||||
<Route path="sitemap" element={<Navigate to="/care/main/sitemap" replace />} />
|
<Route path="sitemap" element={<Navigate to="/care/main/sitemap" replace />} />
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Button, Modal, Space, Typography } from 'antd'
|
||||||
|
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||||
|
|
||||||
|
const SNOOZE_KEY = 'bizcare_announce_popup_snooze'
|
||||||
|
|
||||||
|
type PopupPost = {
|
||||||
|
id: number
|
||||||
|
title?: string
|
||||||
|
contents?: string
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnoozeMap = Record<string, number>
|
||||||
|
|
||||||
|
function readSnooze(): SnoozeMap {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SNOOZE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
const parsed = JSON.parse(raw) as SnoozeMap
|
||||||
|
const now = Date.now()
|
||||||
|
const next: SnoozeMap = {}
|
||||||
|
for (const [id, expires] of Object.entries(parsed || {})) {
|
||||||
|
if (typeof expires === 'number' && expires > now) next[id] = expires
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSnooze(map: SnoozeMap) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SNOOZE_KEY, JSON.stringify(map))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인 후 초기화면 — 팝업 ON 공지 순차 표시 (billing_imeere POST_POPUP_LIST) */
|
||||||
|
export default function NoticePopupQueue() {
|
||||||
|
const [queue, setQueue] = useState<PopupPost[]>([])
|
||||||
|
const [idx, setIdx] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await client.get<ApiResponse<PopupPost[]>>(`${apiPrefix()}/homes/announces/popups`)
|
||||||
|
const list = unwrap(data) || []
|
||||||
|
if (cancelled) return
|
||||||
|
const snooze = readSnooze()
|
||||||
|
writeSnooze(snooze)
|
||||||
|
const now = Date.now()
|
||||||
|
const filtered = list.filter((p) => {
|
||||||
|
const exp = snooze[String(p.id)]
|
||||||
|
return !(exp && exp > now)
|
||||||
|
})
|
||||||
|
setQueue(filtered)
|
||||||
|
setIdx(0)
|
||||||
|
} catch {
|
||||||
|
/* 팝업 실패는 무시 */
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const current = useMemo(() => queue[idx] ?? null, [queue, idx])
|
||||||
|
const advance = () => setIdx((i) => i + 1)
|
||||||
|
|
||||||
|
const snooze = (hours: number) => {
|
||||||
|
if (!current) return
|
||||||
|
const map = readSnooze()
|
||||||
|
map[String(current.id)] = Date.now() + hours * 60 * 60 * 1000
|
||||||
|
writeSnooze(map)
|
||||||
|
advance()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) return null
|
||||||
|
|
||||||
|
const body = current.contents || current.content || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={current.title || '공지사항'}
|
||||||
|
open
|
||||||
|
onCancel={advance}
|
||||||
|
width={560}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
footer={
|
||||||
|
<Space wrap style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={() => snooze(1)}>1시간 동안 보지 않기</Button>
|
||||||
|
<Button onClick={() => snooze(24)}>24시간 동안 보지 않기</Button>
|
||||||
|
<Button type="primary" onClick={advance}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
|
||||||
|
{body}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -144,8 +144,11 @@ export function careMenuSections(): MenuSection[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bbs',
|
id: 'bbs',
|
||||||
title: '사내게시판',
|
title: '공지사항',
|
||||||
items: [{ key: '/care/bbs/bbs', label: '사내게시판' }],
|
items: [
|
||||||
|
{ key: '/care/bbs/notice', label: '공지사항' },
|
||||||
|
{ key: '/care/bbs/bbs', label: '사내게시판' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -194,6 +197,7 @@ export const SITEMAP_TITLE_TO_ID: Record<string, string> = {
|
|||||||
통계관리: 'report',
|
통계관리: 'report',
|
||||||
환경설정: 'setting',
|
환경설정: 'setting',
|
||||||
고객센터: 'cs',
|
고객센터: 'cs',
|
||||||
|
공지사항: 'bbs',
|
||||||
사내게시판: 'bbs',
|
사내게시판: 'bbs',
|
||||||
게시판: 'bbs',
|
게시판: 'bbs',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import Modal from '../../components/DraggableModal'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd'
|
||||||
|
import CareTable from '../../components/CareTable'
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
FormOutlined,
|
||||||
|
NotificationOutlined,
|
||||||
|
PlusSquareOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||||
|
import AnnouncePopupTimeFields from './AnnouncePopupTimeFields'
|
||||||
|
import {
|
||||||
|
buildPopupApiFields,
|
||||||
|
defaultPopupTime,
|
||||||
|
popupTimeFromAnnounce,
|
||||||
|
type PopupTimeState,
|
||||||
|
} from './announcePopupTime'
|
||||||
|
|
||||||
|
type AnnounceRow = {
|
||||||
|
id: number
|
||||||
|
no?: number
|
||||||
|
title?: string
|
||||||
|
authorName?: string
|
||||||
|
views?: number
|
||||||
|
createdDate?: string
|
||||||
|
contents?: string
|
||||||
|
isPopup?: number
|
||||||
|
popupStart?: string
|
||||||
|
popupEnd?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnnounceResult = {
|
||||||
|
total: number
|
||||||
|
list: AnnounceRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = { page: 0, size: 15, searchType: '제목', sort: 'id,desc' } as Record<string, unknown>
|
||||||
|
|
||||||
|
export default function AnnouncePage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [editForm] = Form.useForm()
|
||||||
|
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<AnnounceRow | null>(null)
|
||||||
|
const [detail, setDetail] = useState<AnnounceRow | null>(null)
|
||||||
|
const [popup, setPopup] = useState<PopupTimeState>(defaultPopupTime())
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['homes-announces', filters],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await client.get<ApiResponse<AnnounceResult>>(`${apiPrefix()}/homes/announces`, {
|
||||||
|
params: {
|
||||||
|
...filters,
|
||||||
|
q: filters.keyword || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return unwrap(data)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-announces'] })
|
||||||
|
|
||||||
|
const closeEdit = () => {
|
||||||
|
setOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
editForm.resetFields()
|
||||||
|
setPopup(defaultPopupTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null)
|
||||||
|
editForm.resetFields()
|
||||||
|
setPopup(defaultPopupTime())
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const { data } = await client.get<ApiResponse<AnnounceRow>>(`${apiPrefix()}/homes/announces/${id}`)
|
||||||
|
return unwrap(data)
|
||||||
|
},
|
||||||
|
onSuccess: (row) => {
|
||||||
|
setEditing(row)
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
title: row.title,
|
||||||
|
contents: row.contents,
|
||||||
|
})
|
||||||
|
setPopup(popupTimeFromAnnounce(row))
|
||||||
|
setOpen(true)
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveMut = useMutation({
|
||||||
|
mutationFn: async (values: Record<string, unknown>) => {
|
||||||
|
const body = {
|
||||||
|
title: values.title,
|
||||||
|
contents: values.contents,
|
||||||
|
...buildPopupApiFields(popup),
|
||||||
|
}
|
||||||
|
if (editing?.id) {
|
||||||
|
const { data } = await client.put(`${apiPrefix()}/homes/announces/${editing.id}`, body)
|
||||||
|
return unwrap(data as ApiResponse)
|
||||||
|
}
|
||||||
|
const { data } = await client.post(`${apiPrefix()}/homes/announces`, body)
|
||||||
|
return unwrap(data as ApiResponse)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(editing ? '공지사항을 수정했습니다.' : '공지사항을 등록했습니다.')
|
||||||
|
closeEdit()
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const togglePopup = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const { data } = await client.post(`${apiPrefix()}/homes/announces/${id}/popup`)
|
||||||
|
return unwrap(data as ApiResponse)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('팝업 설정을 변경했습니다.')
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const openDetail = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const { data } = await client.get<ApiResponse<AnnounceRow>>(`${apiPrefix()}/homes/announces/${id}`)
|
||||||
|
return unwrap(data)
|
||||||
|
},
|
||||||
|
onSuccess: (row) => {
|
||||||
|
setDetail(row)
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSearch = (values: Record<string, unknown>) => {
|
||||||
|
setFilters({
|
||||||
|
...initial,
|
||||||
|
searchType: values.searchType || '제목',
|
||||||
|
keyword: values.keyword || undefined,
|
||||||
|
page: 0,
|
||||||
|
size: Number(values.size ?? filters.size ?? 15),
|
||||||
|
sort: filters.sort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
form.resetFields()
|
||||||
|
form.setFieldsValue({ searchType: '제목', size: 15 })
|
||||||
|
setFilters(initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: '번호',
|
||||||
|
dataIndex: 'no',
|
||||||
|
width: 80,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '팝업',
|
||||||
|
dataIndex: 'isPopup',
|
||||||
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (v: number | undefined, row: AnnounceRow) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={v === 1 ? 'primary' : 'default'}
|
||||||
|
loading={togglePopup.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
togglePopup.mutate(row.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v === 1 ? 'ON' : 'OFF'}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '제목',
|
||||||
|
dataIndex: 'title',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string, row: AnnounceRow) => (
|
||||||
|
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||||
|
{v}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '작성자',
|
||||||
|
dataIndex: 'authorName',
|
||||||
|
width: 110,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '조회',
|
||||||
|
dataIndex: 'views',
|
||||||
|
width: 80,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (v?: number) => (v == null ? 0 : Number(v).toLocaleString()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '등록일',
|
||||||
|
dataIndex: 'createdDate',
|
||||||
|
width: 120,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '관리',
|
||||||
|
key: 'actions',
|
||||||
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
|
fixed: 'right' as const,
|
||||||
|
render: (_: unknown, row: AnnounceRow) => (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<FormOutlined />}
|
||||||
|
loading={openEdit.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openEdit.mutate(row.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[togglePopup.isPending, openEdit.isPending],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stock-page cs-notice-page homes-announce-page">
|
||||||
|
<div className="stock-title">
|
||||||
|
<div>
|
||||||
|
<h2>
|
||||||
|
<NotificationOutlined className="product-title-icon" />
|
||||||
|
공지사항
|
||||||
|
</h2>
|
||||||
|
<p>사내 공지사항을 등록·관리합니다. 팝업 ON 항목은 로그인 후 초기화면에 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||||
|
공지 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="stock-filter-card" size="small">
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
className="farebox-filter"
|
||||||
|
initialValues={{ searchType: '제목', size: 15 }}
|
||||||
|
onFinish={onSearch}
|
||||||
|
>
|
||||||
|
<div className="farebox-filter-row">
|
||||||
|
<Form.Item name="searchType">
|
||||||
|
<Select className="w-110" options={['제목', '직원', '내용'].map((v) => ({ value: v, label: v }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="keyword">
|
||||||
|
<Input className="w-230" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={onReset}>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="size" className="farebox-size">
|
||||||
|
<Select
|
||||||
|
className="w-150"
|
||||||
|
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||||
|
onChange={(size) => {
|
||||||
|
form.setFieldValue('size', size)
|
||||||
|
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="stock-table-card" size="small">
|
||||||
|
<div className="farebox-table-toolbar">
|
||||||
|
<Typography.Text>
|
||||||
|
목록 <b>{query.data?.total ?? 0}</b>
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<CareTable
|
||||||
|
size="small"
|
||||||
|
loading={query.isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={query.data?.list}
|
||||||
|
columns={columns}
|
||||||
|
bordered
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
pagination={{
|
||||||
|
current: Number(filters.page ?? 0) + 1,
|
||||||
|
pageSize: Number(filters.size ?? 15),
|
||||||
|
total: query.data?.total ?? 0,
|
||||||
|
showSizeChanger: false,
|
||||||
|
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })),
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: '등록된 공지사항이 없습니다.' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '공지사항 수정' : '공지사항 등록'}
|
||||||
|
open={open}
|
||||||
|
onCancel={closeEdit}
|
||||||
|
footer={null}
|
||||||
|
width={640}
|
||||||
|
destroyOnHidden
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form form={editForm} layout="vertical" onFinish={(values) => saveMut.mutate(values)}>
|
||||||
|
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
||||||
|
<Input placeholder="제목을 입력하세요" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||||
|
<Input.TextArea rows={10} placeholder="내용을 입력하세요" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="팝업">
|
||||||
|
<AnnouncePopupTimeFields value={popup} onChange={setPopup} />
|
||||||
|
</Form.Item>
|
||||||
|
<div className="sms-send-footer">
|
||||||
|
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||||
|
{editing ? '수정' : '등록'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={closeEdit}>취소</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="공지사항"
|
||||||
|
open={Boolean(detail)}
|
||||||
|
onCancel={() => setDetail(null)}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
{detail ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<FormOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setDetail(null)
|
||||||
|
openEdit.mutate(detail.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button onClick={() => setDetail(null)}>닫기</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
width={640}
|
||||||
|
destroyOnHidden
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{detail ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
{detail.title}
|
||||||
|
</Typography.Title>
|
||||||
|
<div className="cs-notice-meta">
|
||||||
|
<span>작성자 {detail.authorName || '-'}</span>
|
||||||
|
<span>조회 {detail.views ?? 0}</span>
|
||||||
|
<span>등록일 {detail.createdDate || '-'}</span>
|
||||||
|
<span>팝업 {detail.isPopup === 1 ? 'ON' : 'OFF'}</span>
|
||||||
|
</div>
|
||||||
|
{detail.isPopup === 1 && (
|
||||||
|
<div className="cs-notice-meta">
|
||||||
|
<span>
|
||||||
|
기간 {popupTimeFromAnnounce(detail).startDate} ~ {popupTimeFromAnnounce(detail).endDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="cs-notice-body">
|
||||||
|
{(detail.contents || '')
|
||||||
|
.split('\n')
|
||||||
|
.map((line, idx) => (
|
||||||
|
<p key={idx}>{line || '\u00A0'}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Checkbox, DatePicker, Select, Space } from 'antd'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import {
|
||||||
|
HOUR_OPTIONS,
|
||||||
|
MIN_SEC_OPTIONS,
|
||||||
|
type PopupTimeState,
|
||||||
|
} from './announcePopupTime'
|
||||||
|
|
||||||
|
/** 팝업 체크 + 시작/종료 일시 (billing_imeere posts/create 팝업 행) */
|
||||||
|
export default function AnnouncePopupTimeFields({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: PopupTimeState
|
||||||
|
onChange: (next: PopupTimeState) => void
|
||||||
|
}) {
|
||||||
|
const set = (patch: Partial<PopupTimeState>) => onChange({ ...value, ...patch })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="announce-popup-fields">
|
||||||
|
<Checkbox checked={value.isPopup} onChange={(e) => set({ isPopup: e.target.checked })}>
|
||||||
|
팝업
|
||||||
|
</Checkbox>
|
||||||
|
<div className="announce-popup-time-row">
|
||||||
|
<DatePicker
|
||||||
|
value={value.startDate ? dayjs(value.startDate) : null}
|
||||||
|
onChange={(d) => set({ startDate: d ? d.format('YYYY-MM-DD') : '' })}
|
||||||
|
allowClear={false}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
className="w-70"
|
||||||
|
value={value.startHour}
|
||||||
|
options={HOUR_OPTIONS.map((h) => ({ value: h, label: h }))}
|
||||||
|
onChange={(v) => set({ startHour: v })}
|
||||||
|
/>
|
||||||
|
<span>시</span>
|
||||||
|
<Select
|
||||||
|
className="w-70"
|
||||||
|
value={value.startMin}
|
||||||
|
options={MIN_SEC_OPTIONS.map((m) => ({ value: m, label: m }))}
|
||||||
|
onChange={(v) => set({ startMin: v })}
|
||||||
|
/>
|
||||||
|
<span>분</span>
|
||||||
|
<Select
|
||||||
|
className="w-70"
|
||||||
|
value={value.startSec}
|
||||||
|
options={MIN_SEC_OPTIONS.map((s) => ({ value: s, label: s }))}
|
||||||
|
onChange={(v) => set({ startSec: v })}
|
||||||
|
/>
|
||||||
|
<span>초 부터</span>
|
||||||
|
</div>
|
||||||
|
<div className="announce-popup-time-row">
|
||||||
|
<Space wrap>
|
||||||
|
<DatePicker
|
||||||
|
value={value.endDate ? dayjs(value.endDate) : null}
|
||||||
|
onChange={(d) => set({ endDate: d ? d.format('YYYY-MM-DD') : '' })}
|
||||||
|
allowClear={false}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
className="w-70"
|
||||||
|
value={value.endHour}
|
||||||
|
options={HOUR_OPTIONS.map((h) => ({ value: h, label: h }))}
|
||||||
|
onChange={(v) => set({ endHour: v })}
|
||||||
|
/>
|
||||||
|
<span>시</span>
|
||||||
|
<Select
|
||||||
|
className="w-70"
|
||||||
|
value={value.endMin}
|
||||||
|
options={MIN_SEC_OPTIONS.map((m) => ({ value: m, label: m }))}
|
||||||
|
onChange={(v) => set({ endMin: v })}
|
||||||
|
/>
|
||||||
|
<span>분</span>
|
||||||
|
<Select
|
||||||
|
className="w-70"
|
||||||
|
value={value.endSec}
|
||||||
|
options={MIN_SEC_OPTIONS.map((s) => ({ value: s, label: s }))}
|
||||||
|
onChange={(v) => set({ endSec: v })}
|
||||||
|
/>
|
||||||
|
<span>초 까지</span>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
|||||||
import { homeAccentColors } from '../../theme'
|
import { homeAccentColors } from '../../theme'
|
||||||
import { useTheme } from '../../themeContext'
|
import { useTheme } from '../../themeContext'
|
||||||
import { money } from './homesShared'
|
import { money } from './homesShared'
|
||||||
|
import NoticePopupQueue from '../../components/NoticePopupQueue'
|
||||||
|
|
||||||
type SparkBlock = {
|
type SparkBlock = {
|
||||||
open?: number
|
open?: number
|
||||||
@@ -490,6 +491,8 @@ export default function HomeDashboardPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<NoticePopupQueue />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const MENU_EXPOSURE = [
|
|||||||
{ key: 'abooks', label: '장부관리' },
|
{ key: 'abooks', label: '장부관리' },
|
||||||
{ key: 'report', label: '통계관리' },
|
{ key: 'report', label: '통계관리' },
|
||||||
{ key: 'cs', label: '고객센터' },
|
{ key: 'cs', label: '고객센터' },
|
||||||
{ key: 'bbs', label: '사내게시판' },
|
{ key: 'bbs', label: '공지사항' },
|
||||||
{ key: 'setting', label: '환경설정' },
|
{ key: 'setting', label: '환경설정' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -166,8 +166,11 @@ const SITEMAP_COLUMNS: SitemapSection[][] = [
|
|||||||
] },
|
] },
|
||||||
{
|
{
|
||||||
letter: 'M',
|
letter: 'M',
|
||||||
title: '게시판',
|
title: '공지사항',
|
||||||
items: [{ label: '사내게시판', to: '/care/bbs/bbs' }] },
|
items: [
|
||||||
|
{ label: '공지사항', to: '/care/bbs/notice' },
|
||||||
|
{ label: '사내게시판', to: '/care/bbs/bbs' },
|
||||||
|
] },
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/** 공지 팝업 노출 기간 (billing_imeere / billing_react 대응) */
|
||||||
|
export type PopupTimeState = {
|
||||||
|
isPopup: boolean
|
||||||
|
startDate: string
|
||||||
|
startHour: string
|
||||||
|
startMin: string
|
||||||
|
startSec: string
|
||||||
|
endDate: string
|
||||||
|
endHour: string
|
||||||
|
endMin: string
|
||||||
|
endSec: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
export function defaultPopupTime(): PopupTimeState {
|
||||||
|
const now = new Date()
|
||||||
|
const date = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`
|
||||||
|
const h = pad2(now.getHours())
|
||||||
|
const m = pad2(now.getMinutes())
|
||||||
|
const s = pad2(now.getSeconds())
|
||||||
|
return {
|
||||||
|
isPopup: false,
|
||||||
|
startDate: date,
|
||||||
|
startHour: h,
|
||||||
|
startMin: m,
|
||||||
|
startSec: s,
|
||||||
|
endDate: date,
|
||||||
|
endHour: '23',
|
||||||
|
endMin: '59',
|
||||||
|
endSec: '59',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitDateTime(v?: string | null) {
|
||||||
|
if (!v) return { date: '', hour: '00', min: '00', sec: '00' }
|
||||||
|
const s = String(v).replace('T', ' ')
|
||||||
|
return {
|
||||||
|
date: s.substring(0, 10),
|
||||||
|
hour: s.substring(11, 13) || '00',
|
||||||
|
min: s.substring(14, 16) || '00',
|
||||||
|
sec: s.substring(17, 19) || '00',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function popupTimeFromAnnounce(p: {
|
||||||
|
isPopup?: number
|
||||||
|
popupStart?: string | null
|
||||||
|
popupEnd?: string | null
|
||||||
|
}): PopupTimeState {
|
||||||
|
const st = splitDateTime(p.popupStart)
|
||||||
|
const en = splitDateTime(p.popupEnd)
|
||||||
|
const base = defaultPopupTime()
|
||||||
|
return {
|
||||||
|
isPopup: p.isPopup === 1,
|
||||||
|
startDate: st.date || base.startDate,
|
||||||
|
startHour: st.hour,
|
||||||
|
startMin: st.min,
|
||||||
|
startSec: st.sec,
|
||||||
|
endDate: en.date || base.endDate,
|
||||||
|
endHour: en.hour,
|
||||||
|
endMin: en.min,
|
||||||
|
endSec: en.sec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDateTime(date: string, hour: string, min: string, sec: string) {
|
||||||
|
return `${date}T${hour}:${min}:${sec}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPopupApiFields(popup: PopupTimeState) {
|
||||||
|
return {
|
||||||
|
isPopup: popup.isPopup ? 1 : 0,
|
||||||
|
popupStart: popup.isPopup
|
||||||
|
? toLocalDateTime(popup.startDate, popup.startHour, popup.startMin, popup.startSec)
|
||||||
|
: null,
|
||||||
|
popupEnd: popup.isPopup
|
||||||
|
? toLocalDateTime(popup.endDate, popup.endHour, popup.endMin, popup.endSec)
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => pad2(i))
|
||||||
|
export const MIN_SEC_OPTIONS = Array.from({ length: 60 }, (_, i) => pad2(i))
|
||||||
@@ -5147,3 +5147,32 @@ html.theme-light .farebox-gray-btn:not(:disabled):hover {
|
|||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 공지사항 팝업 기간 입력 */
|
||||||
|
.announce-popup-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.announce-popup-time-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.announce-popup-time-row .w-70 {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
.homes-announce-page .cs-notice-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.homes-announce-page .cs-notice-body {
|
||||||
|
margin-top: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# Jenkins Job 설치/갱신 — BizCare-Pipeline (Track B)
|
||||||
|
# 서버에서 실행: bash scripts/install-jenkins-bizcare-job.sh
|
||||||
|
# ==============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bin:/bin}"
|
||||||
|
|
||||||
|
JENKINS_HOME="${JENKINS_HOME:-/Users/aidev/.jenkins}"
|
||||||
|
JENKINS_URL="${JENKINS_URL:-http://127.0.0.1:8090}"
|
||||||
|
JOB_NEW="BizCare-Pipeline"
|
||||||
|
JOB_OLD="BillCare-Pipeline"
|
||||||
|
BARE_REPO="file:///Volumes/ADATA/git/BizCare.git"
|
||||||
|
DEPLOY_SCRIPT="/Volumes/ADATA/git/deploy-bizcare.sh"
|
||||||
|
|
||||||
|
write_job() {
|
||||||
|
local job="$1" desc="$2"
|
||||||
|
local dir="${JENKINS_HOME}/jobs/${job}"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
cat > "${dir}/config.xml" <<EOF
|
||||||
|
<?xml version='1.1' encoding='UTF-8'?>
|
||||||
|
<project>
|
||||||
|
<description>${desc}</description>
|
||||||
|
<keepDependencies>false</keepDependencies>
|
||||||
|
<properties/>
|
||||||
|
<scm class="hudson.plugins.git.GitSCM" plugin="git@5.0.0">
|
||||||
|
<configVersion>2</configVersion>
|
||||||
|
<userRemoteConfigs>
|
||||||
|
<hudson.plugins.git.UserRemoteConfig>
|
||||||
|
<url>${BARE_REPO}</url>
|
||||||
|
</hudson.plugins.git.UserRemoteConfig>
|
||||||
|
</userRemoteConfigs>
|
||||||
|
<branches>
|
||||||
|
<hudson.plugins.git.BranchSpec>
|
||||||
|
<name>*/main</name>
|
||||||
|
</hudson.plugins.git.BranchSpec>
|
||||||
|
</branches>
|
||||||
|
<doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
|
||||||
|
<submoduleCfg class="empty-list"/>
|
||||||
|
<extensions/>
|
||||||
|
</scm>
|
||||||
|
<canRoam>true</canRoam>
|
||||||
|
<disabled>false</disabled>
|
||||||
|
<blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
|
||||||
|
<blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
|
||||||
|
<triggers/>
|
||||||
|
<concurrentBuild>false</concurrentBuild>
|
||||||
|
<builders>
|
||||||
|
<hudson.tasks.Shell>
|
||||||
|
<command>#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:\$PATH"
|
||||||
|
export GIT_DIR="/Volumes/ADATA/git/BizCare.git"
|
||||||
|
export WORK_TREE="/Users/aidev/apps/BizCare"
|
||||||
|
echo "=== BizCare Jenkins Build #\${BUILD_NUMBER} ==="
|
||||||
|
echo "시각: \$(date)"
|
||||||
|
echo "GIT_DIR=\$GIT_DIR"
|
||||||
|
echo "WORK_TREE=\$WORK_TREE"
|
||||||
|
SERVICE_NAME="\${SERVICE_NAME:-all}" ${DEPLOY_SCRIPT}
|
||||||
|
</command>
|
||||||
|
</hudson.tasks.Shell>
|
||||||
|
</builders>
|
||||||
|
<publishers/>
|
||||||
|
<buildWrappers/>
|
||||||
|
</project>
|
||||||
|
EOF
|
||||||
|
echo "[OK] wrote ${dir}/config.xml"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "======================================================"
|
||||||
|
echo " Jenkins BizCare job 설치 $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "======================================================"
|
||||||
|
|
||||||
|
[ -d "$JENKINS_HOME" ] || { echo "ERROR: JENKINS_HOME 없음: $JENKINS_HOME"; exit 1; }
|
||||||
|
[ -x "$DEPLOY_SCRIPT" ] || { echo "ERROR: deploy 스크립트 없음: $DEPLOY_SCRIPT"; exit 1; }
|
||||||
|
[ -d /Volumes/ADATA/git/BizCare.git ] || { echo "ERROR: BizCare.git 없음"; exit 1; }
|
||||||
|
|
||||||
|
write_job "$JOB_NEW" "BizCare — Track B (deploy-bizcare.sh, file:// BizCare.git)"
|
||||||
|
write_job "$JOB_OLD" "BillCare → BizCare 위임 — Track B (deploy-bizcare.sh, file:// BizCare.git)"
|
||||||
|
|
||||||
|
# Jenkins에 설정 리로드
|
||||||
|
if curl -sf -o /dev/null "${JENKINS_URL}/login" 2>/dev/null; then
|
||||||
|
echo "[INFO] Jenkins reloadConfiguration 시도: $JENKINS_URL"
|
||||||
|
# crumb (보안 켜져 있으면)
|
||||||
|
CRUMB_JSON="$(curl -sf "${JENKINS_URL}/crumbIssuer/api/json" 2>/dev/null || true)"
|
||||||
|
HDR=()
|
||||||
|
if [ -n "$CRUMB_JSON" ]; then
|
||||||
|
FIELD="$(printf '%s' "$CRUMB_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('crumbRequestField',''))" 2>/dev/null || true)"
|
||||||
|
VAL="$(printf '%s' "$CRUMB_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('crumb',''))" 2>/dev/null || true)"
|
||||||
|
if [ -n "$FIELD" ] && [ -n "$VAL" ]; then
|
||||||
|
HDR=(-H "${FIELD}:${VAL}")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if curl -sf "${HDR[@]}" -X POST "${JENKINS_URL}/reload" >/dev/null 2>&1 \
|
||||||
|
|| curl -sf "${HDR[@]}" -X POST "${JENKINS_URL}/reloadConfiguration" >/dev/null 2>&1; then
|
||||||
|
echo "[OK] Jenkins reload 요청 성공"
|
||||||
|
else
|
||||||
|
echo "[WARN] HTTP reload 실패 — Jenkins 재시작 또는 UI에서 'Reload Configuration from Disk' 실행"
|
||||||
|
echo " 또는: launchctl kickstart -k gui/\$(id -u)/homebrew.mxcl.jenkins-lts"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[WARN] Jenkins 미기동 ($JENKINS_URL) — job 파일은 기록됨. Jenkins 기동 후 자동 로드됨"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "======================================================"
|
||||||
|
echo " Job: $JOB_NEW (정본)"
|
||||||
|
echo " Job: $JOB_OLD (구 이름 호환 → 동일 BizCare 배포)"
|
||||||
|
echo " SCM: $BARE_REPO"
|
||||||
|
echo " Deploy: $DEPLOY_SCRIPT"
|
||||||
|
echo "======================================================"
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# 서버 일회성: BillCare → BizCare git/배포 경로 전환
|
||||||
|
# 실행 위치: stock 서버 (aidev)
|
||||||
|
# ==============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bin:/bin}"
|
||||||
|
|
||||||
|
OLD_BARE="/Volumes/ADATA/git/BillCare.git"
|
||||||
|
NEW_BARE="/Volumes/ADATA/git/BizCare.git"
|
||||||
|
OLD_WT="/Users/aidev/apps/BillCare"
|
||||||
|
NEW_WT="/Users/aidev/apps/BizCare"
|
||||||
|
DEPLOY_SRC_CANDIDATES=(
|
||||||
|
"${NEW_WT}/scripts/deploy.sh"
|
||||||
|
"${OLD_WT}/scripts/deploy.sh"
|
||||||
|
)
|
||||||
|
HOOK_SRC_CANDIDATES=(
|
||||||
|
"${NEW_WT}/scripts/post-receive"
|
||||||
|
"${OLD_WT}/scripts/post-receive"
|
||||||
|
)
|
||||||
|
DEPLOY_DST="/Volumes/ADATA/git/deploy-bizcare.sh"
|
||||||
|
HOOK_DST="${NEW_BARE}/hooks/post-receive"
|
||||||
|
OLD_HOOK="${OLD_BARE}/hooks/post-receive"
|
||||||
|
LOG="/Volumes/ADATA/logs/deploy/bizcare-migrate.log"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$LOG")" "$NEW_WT"
|
||||||
|
exec > >(tee -a "$LOG") 2>&1
|
||||||
|
|
||||||
|
echo "======================================================"
|
||||||
|
echo " BillCare → BizCare 마이그레이션 $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "======================================================"
|
||||||
|
|
||||||
|
[ -d "$OLD_BARE" ] || { echo "ERROR: $OLD_BARE 없음"; exit 1; }
|
||||||
|
|
||||||
|
# 1) BizCare.git bare 생성 (없으면 BillCare 미러)
|
||||||
|
if [ ! -d "$NEW_BARE" ]; then
|
||||||
|
echo "[1] BizCare.git 미러 생성"
|
||||||
|
git clone --mirror "$OLD_BARE" "$NEW_BARE"
|
||||||
|
else
|
||||||
|
echo "[1] BizCare.git 존재 — BillCare 내용 fetch"
|
||||||
|
git --git-dir="$NEW_BARE" fetch --all --prune || true
|
||||||
|
# BillCare 최신 main 반영
|
||||||
|
git --git-dir="$NEW_BARE" fetch "$OLD_BARE" '+refs/heads/*:refs/heads/*' || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) work-tree 동기화
|
||||||
|
echo "[2] work-tree 체크아웃 → $NEW_WT"
|
||||||
|
git --git-dir="$NEW_BARE" --work-tree="$NEW_WT" checkout -f main
|
||||||
|
|
||||||
|
# 3) deploy-bizcare.sh 설치
|
||||||
|
DEPLOY_SRC=""
|
||||||
|
for c in "${DEPLOY_SRC_CANDIDATES[@]}"; do
|
||||||
|
if [ -f "$c" ]; then DEPLOY_SRC="$c"; break; fi
|
||||||
|
done
|
||||||
|
[ -n "$DEPLOY_SRC" ] || { echo "ERROR: deploy.sh 소스 없음"; exit 1; }
|
||||||
|
cp "$DEPLOY_SRC" "$DEPLOY_DST"
|
||||||
|
chmod +x "$DEPLOY_DST"
|
||||||
|
# BillCare 이름 스크립트도 동일 내용으로 맞춤 (구 훅 호환)
|
||||||
|
cp "$DEPLOY_DST" /Volumes/ADATA/git/deploy-billcare.sh
|
||||||
|
chmod +x /Volumes/ADATA/git/deploy-billcare.sh
|
||||||
|
echo "[3] deploy-bizcare.sh 설치: $DEPLOY_DST"
|
||||||
|
|
||||||
|
# 4) BizCare post-receive 설치
|
||||||
|
HOOK_SRC=""
|
||||||
|
for c in "${HOOK_SRC_CANDIDATES[@]}"; do
|
||||||
|
if [ -f "$c" ]; then HOOK_SRC="$c"; break; fi
|
||||||
|
done
|
||||||
|
[ -n "$HOOK_SRC" ] || { echo "ERROR: post-receive 소스 없음"; exit 1; }
|
||||||
|
mkdir -p "$(dirname "$HOOK_DST")"
|
||||||
|
cp "$HOOK_SRC" "$HOOK_DST"
|
||||||
|
chmod +x "$HOOK_DST"
|
||||||
|
echo "[4] BizCare post-receive 설치"
|
||||||
|
|
||||||
|
# 5) BillCare.git 훅 → BizCare work-tree/배포로 리다이렉트 (구 remote 호환)
|
||||||
|
cat > "$OLD_HOOK" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# BillCare.git 수신 → BizCare work-tree / deploy-bizcare.sh 로 위임
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bin:/bin}"
|
||||||
|
|
||||||
|
# 수신은 BillCare.git 이지만 배포 정본은 BizCare
|
||||||
|
export GIT_DIR="/Volumes/ADATA/git/BillCare.git"
|
||||||
|
export WORK_TREE="/Users/aidev/apps/BizCare"
|
||||||
|
DEPLOY_SCRIPT="/Volumes/ADATA/git/deploy-bizcare.sh"
|
||||||
|
DEPLOY_LOG="/Volumes/ADATA/logs/deploy/bizcare-deploy.log"
|
||||||
|
HOOK_TAG="[billcare→bizcare-post-receive]"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$DEPLOY_LOG")" "$WORK_TREE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG BillCare.git 수신 → BizCare 배포로 위임"
|
||||||
|
|
||||||
|
while read -r OLDREV NEWREV REFNAME; do
|
||||||
|
BRANCH_NAME="${REFNAME#refs/heads/}"
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG 브랜치: $BRANCH_NAME"
|
||||||
|
|
||||||
|
# BizCare.git 에도 동일 커밋 반영 (정본 bare 동기화)
|
||||||
|
if [ -d /Volumes/ADATA/git/BizCare.git ]; then
|
||||||
|
git --git-dir=/Volumes/ADATA/git/BizCare.git fetch "$GIT_DIR" "+${REFNAME}:${REFNAME}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BRANCH_NAME" = "main" ]; then
|
||||||
|
export SERVICE_NAME="${SERVICE_NAME:-all}"
|
||||||
|
# deploy 스크립트가 BizCare.git 을 기본으로 쓰므로 정본으로 전환
|
||||||
|
export GIT_DIR="/Volumes/ADATA/git/BizCare.git"
|
||||||
|
if [ ! -x "$DEPLOY_SCRIPT" ]; then
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG [ERROR] $DEPLOY_SCRIPT 없음"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"$DEPLOY_SCRIPT" 2>&1 | tee -a "$DEPLOY_LOG"
|
||||||
|
EXIT_CODE=${PIPESTATUS[0]}
|
||||||
|
[ "$EXIT_CODE" -eq 0 ] || exit "$EXIT_CODE"
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG 배포 완료 → https://bizcare.exdev.co.kr"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
EOF
|
||||||
|
chmod +x "$OLD_HOOK"
|
||||||
|
echo "[5] BillCare.git 훅을 BizCare 배포 위임으로 교체"
|
||||||
|
|
||||||
|
# 6) 구 billcare 컨테이너 정리 (포트 83/18082/3308 확보)
|
||||||
|
echo "[6] 구 billcare 컨테이너 중지 + DB 볼륨 보존"
|
||||||
|
if [ -f "$OLD_WT/docker-compose.yml" ]; then
|
||||||
|
(cd "$OLD_WT" && docker compose down --remove-orphans) || true
|
||||||
|
fi
|
||||||
|
docker rm -f billcare-frontend billcare-backend billcare-mysql 2>/dev/null || true
|
||||||
|
docker rm -f bizcare-frontend bizcare-backend bizcare-mysql 2>/dev/null || true
|
||||||
|
|
||||||
|
# 라이브 데이터가 billcare_billcare_mysql 에 있으면 bizcare 프로젝트 볼륨으로 복사
|
||||||
|
LIVE_DB="billcare_billcare_mysql"
|
||||||
|
LIVE_UP="billcare_billcare_uploads"
|
||||||
|
NEW_DB="bizcare_bizcare_mysql"
|
||||||
|
NEW_UP="bizcare_bizcare_uploads"
|
||||||
|
if docker volume inspect "$LIVE_DB" >/dev/null 2>&1; then
|
||||||
|
echo "[6b] MySQL 볼륨 복사: $LIVE_DB → $NEW_DB"
|
||||||
|
docker volume create "$NEW_DB" >/dev/null
|
||||||
|
docker run --rm -v "$LIVE_DB":/from:ro -v "$NEW_DB":/to alpine:3.20 \
|
||||||
|
sh -c 'if [ ! -f /to/ibdata1 ]; then cp -a /from/. /to/; echo copied-db; else echo target-db-exists-skip; fi'
|
||||||
|
fi
|
||||||
|
if docker volume inspect "$LIVE_UP" >/dev/null 2>&1; then
|
||||||
|
echo "[6c] uploads 볼륨 복사: $LIVE_UP → $NEW_UP"
|
||||||
|
docker volume create "$NEW_UP" >/dev/null
|
||||||
|
docker run --rm -v "$LIVE_UP":/from:ro -v "$NEW_UP":/to alpine:3.20 \
|
||||||
|
sh -c 'mkdir -p /to; cp -a /from/. /to/; echo copied-uploads'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7) BizCare 배포 실행
|
||||||
|
echo "[7] BizCare 배포 실행"
|
||||||
|
export GIT_DIR="$NEW_BARE"
|
||||||
|
export WORK_TREE="$NEW_WT"
|
||||||
|
export SERVICE_NAME=all
|
||||||
|
"$DEPLOY_DST"
|
||||||
|
|
||||||
|
echo "======================================================"
|
||||||
|
echo " 마이그레이션 완료"
|
||||||
|
echo " bare : $NEW_BARE"
|
||||||
|
echo " wt : $NEW_WT"
|
||||||
|
echo " URL : https://bizcare.exdev.co.kr"
|
||||||
|
echo " 로컬: git remote set-url origin ssh://aidev@stock.exdev.co.kr/Volumes/ADATA/git/BizCare.git"
|
||||||
|
echo "======================================================"
|
||||||
Reference in New Issue
Block a user