diff --git a/Jenkinsfile b/Jenkinsfile index df6b25e..9539bdd 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -4,6 +4,11 @@ pipeline { choice(name: 'SERVICE_NAME', choices: ['all', 'frontend', 'backend', 'docker-only'], 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 { stage('Approve') { steps { @@ -18,9 +23,11 @@ pipeline { steps { sh ''' set -euo pipefail - export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:$PATH" echo "=== BizCare Jenkins Build #${BUILD_NUMBER} ===" 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 ''' } diff --git a/backend/src/main/java/com/bizcare/agent/CareHomesController.java b/backend/src/main/java/com/bizcare/agent/CareHomesController.java index 0270456..64ecdbf 100644 --- a/backend/src/main/java/com/bizcare/agent/CareHomesController.java +++ b/backend/src/main/java/com/bizcare/agent/CareHomesController.java @@ -2373,6 +2373,153 @@ class CareHomesController { 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> 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 f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + String searchType = blankToNull(f.get("searchType")); + String q = qOf(f); + List 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> list = new ArrayList<>(); + int i = 0; + for (HomesAnnounce a : filtered.stream().skip(from).limit(Math.min(Math.max(size, 1), 200)).toList()) { + Map row = announceMap(a); + row.put("no", filtered.size() - from - i); + list.add(row); + i++; + } + Map 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 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 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 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 announceComparator(String sort) { + String field = sort == null || sort.isBlank() ? "id,desc" : sort; + boolean asc = field.endsWith(",asc"); + String key = field.split(",")[0]; + Comparator 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 announceMap(HomesAnnounce a) { + Map 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 f, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { String domain = blankToNull(f.get("domain")); diff --git a/backend/src/main/java/com/bizcare/agent/DataInitializer.java b/backend/src/main/java/com/bizcare/agent/DataInitializer.java index e650b01..d8ec657 100644 --- a/backend/src/main/java/com/bizcare/agent/DataInitializer.java +++ b/backend/src/main/java/com/bizcare/agent/DataInitializer.java @@ -2343,6 +2343,20 @@ class DataInitializer implements CommandLineRunner { em.remove(b); } 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) { 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"}, diff --git a/backend/src/main/java/com/bizcare/agent/DomainEntities.java b/backend/src/main/java/com/bizcare/agent/DomainEntities.java index 573a2a9..0a45c96 100644 --- a/backend/src/main/java/com/bizcare/agent/DomainEntities.java +++ b/backend/src/main/java/com/bizcare/agent/DomainEntities.java @@ -413,6 +413,18 @@ class HomesBbs extends BaseEntity { 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 멀티테넌트에 맞게 정리 */ @Entity @Table(name="homes_receptions") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor class HomesReception extends BaseEntity { diff --git a/deploy_server_guide.md b/deploy_server_guide.md index 8aee9d4..97cf90a 100644 --- a/deploy_server_guide.md +++ b/deploy_server_guide.md @@ -5,18 +5,20 @@ | 트랙 | 트리거 | 동작 | |------|--------|------| | 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`) | -| work-tree | `/Users/aidev/apps/BizCare` (또는 기존 `BillCare`) | +| bare repo (정본) | `/Volumes/ADATA/git/BizCare.git` | +| bare repo (구) | `/Volumes/ADATA/git/BillCare.git` → BizCare 배포로 위임 | +| work-tree | `/Users/aidev/apps/BizCare` | | 배포 스크립트 | `/Volumes/ADATA/git/deploy-bizcare.sh` | | 로그 | `/Volumes/ADATA/logs/deploy/bizcare-deploy.log` | | HTTPS (정본) | https://bizcare.exdev.co.kr | | HTTPS (구) | https://billcare.exdev.co.kr → bizcare 로 301 | +| Jenkins | http://127.0.0.1:8090/ — Job `BizCare-Pipeline` | ## 호스트 포트 @@ -26,6 +28,14 @@ | backend (직접 헬스체크) | **18082** | | 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 ```bash @@ -33,6 +43,21 @@ cd /Users/macbook/dev/billCare 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 활성화 ### 1) DNS diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be0708a..592dcf7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,6 +61,7 @@ import SettingUsageReportPage from './pages/care/SettingUsageReportPage' import CsNoticePage from './pages/care/CsNoticePage' import CsQuestionPage from './pages/care/CsQuestionPage' import BbsPage from './pages/care/BbsPage' +import AnnouncePage from './pages/care/AnnouncePage' import SitemapPage from './pages/care/SitemapPage' function ProtectedRoute() { @@ -181,6 +182,7 @@ export default function App() { } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/NoticePopupQueue.tsx b/frontend/src/components/NoticePopupQueue.tsx new file mode 100644 index 0000000..e4ef6c5 --- /dev/null +++ b/frontend/src/components/NoticePopupQueue.tsx @@ -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 + +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([]) + const [idx, setIdx] = useState(0) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const { data } = await client.get>(`${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 ( + + + + + + } + > + + {body} + + + ) +} diff --git a/frontend/src/menu.ts b/frontend/src/menu.ts index 690c2f3..01b7287 100644 --- a/frontend/src/menu.ts +++ b/frontend/src/menu.ts @@ -144,8 +144,11 @@ export function careMenuSections(): MenuSection[] { }, { id: 'bbs', - title: '사내게시판', - items: [{ key: '/care/bbs/bbs', label: '사내게시판' }], + title: '공지사항', + items: [ + { key: '/care/bbs/notice', label: '공지사항' }, + { key: '/care/bbs/bbs', label: '사내게시판' }, + ], }, ] } @@ -194,6 +197,7 @@ export const SITEMAP_TITLE_TO_ID: Record = { 통계관리: 'report', 환경설정: 'setting', 고객센터: 'cs', + 공지사항: 'bbs', 사내게시판: 'bbs', 게시판: 'bbs', } diff --git a/frontend/src/pages/care/AnnouncePage.tsx b/frontend/src/pages/care/AnnouncePage.tsx new file mode 100644 index 0000000..8d068ef --- /dev/null +++ b/frontend/src/pages/care/AnnouncePage.tsx @@ -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 + +export default function AnnouncePage() { + const qc = useQueryClient() + const [form] = Form.useForm() + const [editForm] = Form.useForm() + const [filters, setFilters] = useState>(initial) + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [detail, setDetail] = useState(null) + const [popup, setPopup] = useState(defaultPopupTime()) + + const query = useQuery({ + queryKey: ['homes-announces', filters], + queryFn: async () => { + const { data } = await client.get>(`${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>(`${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) => { + 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>(`${apiPrefix()}/homes/announces/${id}`) + return unwrap(data) + }, + onSuccess: (row) => { + setDetail(row) + refresh() + }, + onError: (e: Error) => message.error(e.message), + }) + + const onSearch = (values: Record) => { + 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) => ( + + ), + }, + { + title: '제목', + dataIndex: 'title', + ellipsis: true, + render: (v: string, row: AnnounceRow) => ( + + ), + }, + { + 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) => ( + + ), + }, + ], + [togglePopup.isPending, openEdit.isPending], + ) + + return ( +
+
+
+

+ + 공지사항 +

+

사내 공지사항을 등록·관리합니다. 팝업 ON 항목은 로그인 후 초기화면에 표시됩니다.

+
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + +
+ + +
+ + + + setDetail(null)} + footer={ + + {detail ? ( + + ) : null} + + + } + width={640} + destroyOnHidden + centered + > + {detail ? ( +
+ + {detail.title} + +
+ 작성자 {detail.authorName || '-'} + 조회 {detail.views ?? 0} + 등록일 {detail.createdDate || '-'} + 팝업 {detail.isPopup === 1 ? 'ON' : 'OFF'} +
+ {detail.isPopup === 1 && ( +
+ + 기간 {popupTimeFromAnnounce(detail).startDate} ~ {popupTimeFromAnnounce(detail).endDate} + +
+ )} +
+ {(detail.contents || '') + .split('\n') + .map((line, idx) => ( +

{line || '\u00A0'}

+ ))} +
+
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/pages/care/AnnouncePopupTimeFields.tsx b/frontend/src/pages/care/AnnouncePopupTimeFields.tsx new file mode 100644 index 0000000..ecb1e94 --- /dev/null +++ b/frontend/src/pages/care/AnnouncePopupTimeFields.tsx @@ -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) => onChange({ ...value, ...patch }) + + return ( +
+ set({ isPopup: e.target.checked })}> + 팝업 + +
+ set({ startDate: d ? d.format('YYYY-MM-DD') : '' })} + allowClear={false} + /> + ({ value: m, label: m }))} + onChange={(v) => set({ startMin: v })} + /> + 분 + ({ value: h, label: h }))} + onChange={(v) => set({ endHour: v })} + /> + 시 + ({ value: s, label: s }))} + onChange={(v) => set({ endSec: v })} + /> + 초 까지 + +
+
+ ) +} diff --git a/frontend/src/pages/care/HomeDashboardPage.tsx b/frontend/src/pages/care/HomeDashboardPage.tsx index 9799573..5871a1d 100644 --- a/frontend/src/pages/care/HomeDashboardPage.tsx +++ b/frontend/src/pages/care/HomeDashboardPage.tsx @@ -16,6 +16,7 @@ import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' import { homeAccentColors } from '../../theme' import { useTheme } from '../../themeContext' import { money } from './homesShared' +import NoticePopupQueue from '../../components/NoticePopupQueue' type SparkBlock = { open?: number @@ -490,6 +491,8 @@ export default function HomeDashboardPage() { + +
) } diff --git a/frontend/src/pages/care/SettingMemberPage.tsx b/frontend/src/pages/care/SettingMemberPage.tsx index c593db1..6ffce99 100644 --- a/frontend/src/pages/care/SettingMemberPage.tsx +++ b/frontend/src/pages/care/SettingMemberPage.tsx @@ -61,7 +61,7 @@ const MENU_EXPOSURE = [ { key: 'abooks', label: '장부관리' }, { key: 'report', label: '통계관리' }, { key: 'cs', label: '고객센터' }, - { key: 'bbs', label: '사내게시판' }, + { key: 'bbs', label: '공지사항' }, { key: 'setting', label: '환경설정' }, ] diff --git a/frontend/src/pages/care/SitemapPage.tsx b/frontend/src/pages/care/SitemapPage.tsx index 9f5f708..1df52f0 100644 --- a/frontend/src/pages/care/SitemapPage.tsx +++ b/frontend/src/pages/care/SitemapPage.tsx @@ -166,8 +166,11 @@ const SITEMAP_COLUMNS: SitemapSection[][] = [ ] }, { letter: 'M', - title: '게시판', - items: [{ label: '사내게시판', to: '/care/bbs/bbs' }] }, + title: '공지사항', + items: [ + { label: '공지사항', to: '/care/bbs/notice' }, + { label: '사내게시판', to: '/care/bbs/bbs' }, + ] }, ], ] diff --git a/frontend/src/pages/care/announcePopupTime.ts b/frontend/src/pages/care/announcePopupTime.ts new file mode 100644 index 0000000..eaca7f1 --- /dev/null +++ b/frontend/src/pages/care/announcePopupTime.ts @@ -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)) diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 6a5094f..e056aab 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -5147,3 +5147,32 @@ html.theme-light .farebox-gray-btn:not(:disabled):hover { 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; +} + diff --git a/scripts/install-jenkins-bizcare-job.sh b/scripts/install-jenkins-bizcare-job.sh new file mode 100644 index 0000000..ffa6a62 --- /dev/null +++ b/scripts/install-jenkins-bizcare-job.sh @@ -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" < + + ${desc} + false + + + 2 + + + ${BARE_REPO} + + + + + */main + + + false + + + + true + false + false + false + + false + + + #!/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} + + + + + + +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 "======================================================" diff --git a/scripts/migrate-billcare-to-bizcare.sh b/scripts/migrate-billcare-to-bizcare.sh new file mode 100644 index 0000000..43af54e --- /dev/null +++ b/scripts/migrate-billcare-to-bizcare.sh @@ -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 "======================================================"