Skip to content

[FEAT] 시스템 공지 관리 화면 (공지 작성·발행 이력) #72 - #73

Merged
hyun030514 merged 4 commits into
developfrom
feat/system-notice
Aug 4, 2026
Merged

hyun030514 merged 4 commits into
developfrom
feat/system-notice

Conversation

@CallmeEterHist

@CallmeEterHist CallmeEterHist commented Aug 4, 2026 •

Copy link
Copy Markdown
Member

📋 PR 타입

  • feature — 새로운 기능 추가

🗂 작업 영역

  • 공통 · 인프라 (SYSTEM 운영 도구)

🙋 관련 역할

  • SYSTEM (서비스 운영자 — 확장, 데모 제외)

📝 작업 내용

  • /system/notice 화면 신규 — 좌 공지 작성 폼 + 우 발행 이력 (1440 2컬럼)
  • 공지 작성: 제목·내용·발행 대상 입력, 제목·내용 비면 발행 버튼 비활성, 발행 시 인라인 완료 안내
  • 격리막 추가: NOTICE_TARGET 상수/라벨 · NoticeHistoryItem 타입 · mock/notices.ts · getNoticeHistory · publishNoticeAction
  • 사이드바 공지 메뉴 활성화(isReady) · 아이콘 Megaphone

✅ 작업 체크리스트

기본

  • 브랜치 명명 규칙 준수 (feature/system-notice#{이슈번호}, base develop)
  • 커밋 컨벤션 준수
  • 아래 Closes #에 이슈 번호 기입
  • 불필요한 console·주석 코드 제거
  • 민감 정보 없음

Z 필수

  • 🔐 권한 — SYSTEM 전용 라우트 그룹, 발행은 Server Action에서 제목·내용 재검증
  • 🧬 zod — 해당 없음 (목 단계, 스키마 파생 없음 — BE 연동 시 도입)
  • 🕵️ 정직성 — getNoticeHistory·publishNoticeAction 목/미구현 주석 명시, 실제 발송 안 됨
  • 🎨 디자인 토큰 사용 (생 hex 없음) · 다크 값 정의됨

품질

  • ⚡ 최적화 — 무거운 의존성 없어 dynamic import 불필요(사유 명시), 시맨틱 태그 사용
  • ♿ a11y — label htmlFor, 버튼 이름, Select aria
  • ♻️ 재사용 확인 — Button/Input/Label/Select UI 프리미티브 재사용
  • 🧪 loading / error / empty 3상태 (발행 이력 empty 포함)

🔗 연관 이슈

Closes #72

📸 스크린샷 (선택)

Before After

💬 리뷰 포인트

  • 발행 성공 피드백을 인라인 박스로 둠(레퍼런스 UX 그대로). 토스트 대신 인라인이 적절한지 확인 부탁
  • SelectValue render-prop으로 라벨 표시 — base-ui가 팝업 열기 전 값(ALL)을 그대로 보이는 기존 앱 공통 quirk를 이 화면에서만 우회

📝 추가 메모

Summary by CodeRabbit

  • 새 기능
    • 시스템 모니터링 페이지에서 큐 현황, 단계별 처리 시간, 실패 항목을 확인할 수 있습니다.
    • 실패한 파이프라인을 재처리할 수 있습니다.
    • 공지 관리에서 공지 작성·발행 및 발행 이력을 확인할 수 있습니다.
    • 모니터링·공지 페이지의 로딩 및 오류 화면을 제공합니다.
  • 개선
    • 대시보드, 청구, 승인, 기업 관리 화면의 정보 밀도와 가독성을 개선했습니다.
    • 상태와 주요 지표를 의미 있는 색상으로 구분하고 테이블 표시를 간결하게 다듬었습니다.
    • 운영자 화면의 탐색 메뉴와 배경 스타일을 정리했습니다.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

시스템 모니터링과 공지 관리 화면을 추가했습니다. 모니터링 실패 항목 재처리와 공지 발행 목 액션을 연결했습니다. 공통 상태 표현, 운영 화면 레이아웃, 테이블과 차트 스타일을 조정했습니다.

Changes

시스템 운영 기능

Layer / File(s) Summary
운영 데이터 계약과 목 데이터
src/constants/domain.ts, src/features/system/types.ts, src/features/system/mock/*, src/features/system/server.ts
파이프라인 단계, 공지 대상, 모니터링 요약, 실패 항목, 공지 이력 타입과 목 데이터 및 조회 함수를 추가했습니다.
모니터링 조회와 실패 파이프라인 재처리
src/app/(system)/system/monitor/*, src/features/system/components/failed-pipeline*, src/features/system/components/stage-timing-card.tsx, src/features/system/components/pipeline-retry-button.tsx, src/features/system/actions.ts
모니터링 페이지에 큐 통계, 단계별 처리 시간, 실패 파이프라인 표를 추가했습니다. 실패 항목의 재처리 액션과 로딩·오류 UI를 연결했습니다.
공지 작성과 발행 이력
src/app/(system)/system/notice/*, src/features/system/components/notice-*.tsx, src/features/system/actions.ts
공지 제목, 내용, 발행 대상을 입력받는 카드와 발행 이력 카드를 추가했습니다. 목 발행 액션과 로딩·오류 UI를 연결했습니다.
상태 배지와 미납 안내 메일 흐름
src/features/system/components/status-badge.tsx, src/features/system/components/subscription-table.tsx, src/features/system/components/notice-mail-dialog.tsx, src/features/system/actions.ts
상태별 StatusBadge를 추가했습니다. 미납 안내 메일 처리를 외부 콜백, useTransition, toast 결과를 사용하는 구조로 변경했습니다.
운영 화면 스타일과 내비게이션 조정
src/app/(system)/*, src/app/globals.css, src/features/system/components/*, src/features/system/mock/*, src/features/system/nav.ts
운영 화면의 패딩, 간격, 테이블 크기, 차트 크기, 카드 톤, 사이드바 스타일과 경로를 조정했습니다. 운영자용 점 그리드 배경을 추가했습니다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant 운영자
  participant SystemMonitorPage
  participant getMonitoringOverview
  participant FailedPipelineTable
  participant PipelineRetryButton
  participant retryPipelineAction
  운영자->>SystemMonitorPage: 모니터링 화면 열기
  SystemMonitorPage->>getMonitoringOverview: 모니터링 개요 조회
  getMonitoringOverview-->>SystemMonitorPage: 큐 통계와 실패 항목 반환
  SystemMonitorPage->>FailedPipelineTable: 실패 항목 표시
  FailedPipelineTable->>PipelineRetryButton: 재처리 버튼 표시
  PipelineRetryButton->>retryPipelineAction: meetingId로 재처리 요청
  retryPipelineAction-->>PipelineRetryButton: 성공 또는 실패 결과
Loading
sequenceDiagram
  participant 운영자
  participant NoticeComposeCard
  participant publishNoticeAction
  participant SystemNoticePage
  participant NoticeHistoryCard
  운영자->>NoticeComposeCard: 제목, 내용, 대상 입력
  NoticeComposeCard->>publishNoticeAction: 공지 발행 요청
  publishNoticeAction-->>NoticeComposeCard: 발행 결과 반환
  NoticeComposeCard-->>운영자: 발행 완료 안내 표시
  SystemNoticePage->>NoticeHistoryCard: 공지 이력 전달
  NoticeHistoryCard-->>운영자: 제목, 대상, 발송 수 표시
Loading

Possibly related PRs

Suggested labels: ui

Suggested reviewers: hyun030514

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning 시스템 모니터링 기능과 대시보드·청구·기업·승인 화면의 광범위한 UI 변경이 [#72] 범위를 벗어난다. 모니터링 및 기존 화면 UI 변경을 별도 PR로 분리하거나 [#72]에서 제거한다.
Docstring Coverage ⚠️ Warning Docstring coverage is 60.87% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 시스템 공지 작성과 발행 이력 화면이라는 주요 변경을 정확하고 간결하게 설명한다.
Linked Issues check ✅ Passed [#72]의 공지 작성 폼, 필수 검증, 목 Server Action, 발행 완료 안내, 발행 이력, SYSTEM 라우트 요구사항을 구현했다.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/system-notice

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/features/system/components/subscription-table.tsx (1)

139-150: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

발송 진행 상태를 모든 닫기 및 대상 변경 동작에 적용하세요.

확인 버튼만 isPending으로 막고 있습니다. 다이얼로그를 닫은 뒤 다른 기업을 선택하면 이전 요청 완료가 새 다이얼로그를 닫습니다.

  • src/features/system/components/subscription-table.tsx#L139-L150: isPending일 때 안내 발송 버튼을 비활성화하세요.
  • src/features/system/components/notice-mail-dialog.tsx#L40-L41: isPending일 때 닫기 요청을 무시하고 showCloseButton={false}를 적용하세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/components/subscription-table.tsx` around lines 139 -
150, Apply the pending-state guard to both affected sites: in
src/features/system/components/subscription-table.tsx lines 139-150, disable the
“안내 발송” Button while isPending; in
src/features/system/components/notice-mail-dialog.tsx lines 40-41, ignore close
requests while isPending and set showCloseButton to false during the pending
state.
🧹 Nitpick comments (3)
src/app/(system)/system/notice/error.tsx (1)

5-5: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Props 타입을 interface로 분리하세요.

세 컴포넌트가 props 객체 타입을 인라인으로 선언합니다. 각 파일에 ErrorProps, SystemNoticeLayoutProps, NoticeHistoryCardProps 인터페이스를 선언하고 함수 파라미터에 사용하세요.

  • src/app/(system)/system/notice/error.tsx#L5-L5: reset props 인터페이스를 선언하세요.
  • src/app/(system)/system/notice/layout.tsx#L6-L6: children props 인터페이스를 선언하세요.
  • src/features/system/components/notice-history-card.tsx#L6-L6: items props 인터페이스를 선언하세요.

코딩 가이드라인에 따라 props 타입을 인터페이스로 명시해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(system)/system/notice/error.tsx at line 5,
src/app/(system)/system/notice/error.tsx:5의 Error 함수에 ErrorProps 인터페이스를 선언하고
reset props 타입으로 사용하세요. src/app/(system)/system/notice/layout.tsx:6의 레이아웃 함수에
SystemNoticeLayoutProps 인터페이스를 선언하고 children 타입으로 사용하세요.
src/features/system/components/notice-history-card.tsx:6의 컴포넌트에
NoticeHistoryCardProps 인터페이스를 선언하고 items props 타입으로 사용하세요.

Source: Coding guidelines

src/features/system/components/stage-timing-card.tsx (1)

18-18: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

props 타입을 명시적 인터페이스로 분리하세요.

세 컴포넌트가 인라인 객체 타입으로 props를 선언합니다. 각 컴포넌트의 props 인터페이스를 선언하고 함수 매개변수에 적용하세요.

  • src/features/system/components/stage-timing-card.tsx#L18-L18: StageTimingCardProps 인터페이스를 선언하세요.
  • src/features/system/components/failed-pipeline-table.tsx#L40-L40: FailedPipelineTableProps 인터페이스를 선언하세요.
  • src/features/system/components/failed-pipeline-table-loader.tsx#L24-L24: FailedPipelineTableLoaderProps 인터페이스를 선언하세요.

As per coding guidelines, props 타입을 인터페이스로 명시하고 any를 사용하지 않는다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/components/stage-timing-card.tsx` at line 18, 분리된 명시적
props 인터페이스를 선언하고 각 컴포넌트 함수 매개변수에 적용하세요.
src/features/system/components/stage-timing-card.tsx 18-18의 StageTimingCard에는
StageTimingCardProps를, src/features/system/components/failed-pipeline-table.tsx
40-40의 FailedPipelineTable에는 FailedPipelineTableProps를,
src/features/system/components/failed-pipeline-table-loader.tsx 24-24의
FailedPipelineTableLoader에는 FailedPipelineTableLoaderProps를 사용하며, 기존 props 필드와
타입은 유지하고 인라인 객체 타입은 제거하세요.

Source: Coding guidelines

src/app/(system)/system/monitor/error.tsx (1)

5-5: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

컴포넌트 props 타입을 인터페이스로 분리하세요.

두 컴포넌트가 인라인 객체 타입을 사용합니다. props 타입을 명시적 인터페이스로 통일하세요.

  • src/app/(system)/system/monitor/error.tsx#L5-L5: reset을 포함하는 props 인터페이스를 선언하고 Error에 적용하세요.
  • src/app/(system)/system/monitor/layout.tsx#L6-L6: children을 포함하는 props 인터페이스를 선언하고 SystemMonitorLayout에 적용하세요.

코딩 가이드라인의 "props 타입을 인터페이스로 명시" 규칙을 적용했습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(system)/system/monitor/error.tsx at line 5, Declare a dedicated
props interface containing reset and apply it to the Error component in
src/app/(system)/system/monitor/error.tsx at lines 5-5. Declare a dedicated
props interface containing children and apply it to SystemMonitorLayout in
src/app/(system)/system/monitor/layout.tsx at lines 6-6, replacing both inline
object prop types.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/features/system/actions.ts`:
- Around line 135-137: Update the successful submission path in the action
handling the shown input validation to append a NoticeHistoryItem to
MOCK_NOTICE_HISTORY and call revalidatePath("/system/notice") before returning
success. Preserve the existing failure response for blank title or content, and
ensure getNoticeHistory() can reflect the newly added notice.
- Around line 125-129: Update publishNoticeAction to accept a required company
identifier or recipient-company list when target is NOTICE_TARGET.SPECIFIC,
while keeping it absent for other target types. Validate on the server that the
identifier is present and that each referenced company is owned or authorized by
the requesting user before publishing.
- Around line 109-137: Update every Server Action in
src/features/system/actions.ts, including retryPipelineAction and
publishNoticeAction, to verify an authenticated session with the SYSTEM role
before any existing logic or mock/API branch runs; reject unauthorized callers
consistently. Also protect the /system/* route boundary with the same
authentication and SYSTEM-role requirement before enabling real integration.

In `@src/features/system/components/approval-table.tsx`:
- Around line 82-88: 모든 표 헤더의 TableHead에 h-[34px] 클래스를 추가하세요.
approval-table.tsx의 82-88행과 27-28행, recent-companies-table.tsx의 30-36행에 있는 각
TableHead를 수정해 두 표의 헤더 높이가 34px이 되도록 하세요.

In `@src/features/system/components/notice-compose-card.tsx`:
- Around line 90-105: Update the `isSent` conditional in the notice compose card
so a successful publish message does not replace the publish `Button`; keep the
button rendered and preserve its existing `canPublish`/`isPending` behavior.
Ensure subsequent edits or publishes remain possible, optionally resetting
`isSent` when the title, content, or target changes.
- Around line 72-87: Update the notice composition flow around the target Select
and publishNoticeAction to collect a company identifier whenever target is
SPECIFIC. Add the company-selection UI for that state, include a stable
companyId in the Server Action input, and validate both the selected target and
company ownership on the server before publishing.
- Around line 29-43: NoticeComposeCard의 직접적인 handlePublish/startTransition 호출을
useActionState 기반 form action으로 전환하고, publishNoticeAction의 결과로 성공 상태를 관리하세요. 제출
버튼은 별도 하위 컴포넌트에서 useFormStatus를 사용해 pending 상태를 표시하고 비활성화하며, 폼 제출 시 제목·내용·대상을
FormData로 전달하도록 유지하세요.

In `@src/features/system/components/pipeline-retry-button.tsx`:
- Around line 36-45: Update the server-side retryPipelineAction flow to validate
the session’s SYSTEM role before the isMock branch and meetingId lookup. Abort
and return the existing failure shape when the caller lacks SYSTEM permission,
while preserving the current authorized retry behavior.

In `@src/features/system/components/plan-donut-chart-loader.tsx`:
- Line 12: Update the loading renderer in the plan donut chart loader so its
reserved area matches the ResponsiveContainer’s 128px height, centering the
existing skeleton within a 128px wrapper or reusing a shared height constant;
preserve the skeleton’s circular styling.

In `@src/features/system/components/stage-timing-card.tsx`:
- Around line 30-46: Update the bar width calculation in the timings map within
the stage-timing component so that when maxSeconds is 0, the width is explicitly
0%; otherwise preserve the existing relative percentage calculation using
timing.avgSeconds and maxSeconds.

In `@src/features/system/components/subscription-table.tsx`:
- Line 18: Update sendUnpaidNoticeAction in the server actions to independently
validate the caller’s SYSTEM role and access to the targeted company, rather
than relying on client route protection; retain the existing mock/company checks
as applicable, and remove the unused ownerEmail value from the action’s return
data.
- Around line 72-80: sendUnpaidNoticeAction 호출을 감싸는 try/catch를 startTransition
콜백에 추가해 서버 액션 예외가 오류 경계로 전파되지 않도록 하세요. 성공 응답은 기존 토스트를 유지하고, 반환된 실패 응답과 예외 발생 모두
target.companyName을 포함한 실패 토스트를 표시하며, 처리 후에는 setNoticeTarget(null)이 실행되도록 하세요.

---

Outside diff comments:
In `@src/features/system/components/subscription-table.tsx`:
- Around line 139-150: Apply the pending-state guard to both affected sites: in
src/features/system/components/subscription-table.tsx lines 139-150, disable the
“안내 발송” Button while isPending; in
src/features/system/components/notice-mail-dialog.tsx lines 40-41, ignore close
requests while isPending and set showCloseButton to false during the pending
state.

---

Nitpick comments:
In `@src/app/`(system)/system/monitor/error.tsx:
- Line 5: Declare a dedicated props interface containing reset and apply it to
the Error component in src/app/(system)/system/monitor/error.tsx at lines 5-5.
Declare a dedicated props interface containing children and apply it to
SystemMonitorLayout in src/app/(system)/system/monitor/layout.tsx at lines 6-6,
replacing both inline object prop types.

In `@src/app/`(system)/system/notice/error.tsx:
- Line 5: src/app/(system)/system/notice/error.tsx:5의 Error 함수에 ErrorProps
인터페이스를 선언하고 reset props 타입으로 사용하세요. src/app/(system)/system/notice/layout.tsx:6의
레이아웃 함수에 SystemNoticeLayoutProps 인터페이스를 선언하고 children 타입으로 사용하세요.
src/features/system/components/notice-history-card.tsx:6의 컴포넌트에
NoticeHistoryCardProps 인터페이스를 선언하고 items props 타입으로 사용하세요.

In `@src/features/system/components/stage-timing-card.tsx`:
- Line 18: 분리된 명시적 props 인터페이스를 선언하고 각 컴포넌트 함수 매개변수에 적용하세요.
src/features/system/components/stage-timing-card.tsx 18-18의 StageTimingCard에는
StageTimingCardProps를, src/features/system/components/failed-pipeline-table.tsx
40-40의 FailedPipelineTable에는 FailedPipelineTableProps를,
src/features/system/components/failed-pipeline-table-loader.tsx 24-24의
FailedPipelineTableLoader에는 FailedPipelineTableLoaderProps를 사용하며, 기존 props 필드와
타입은 유지하고 인라인 객체 타입은 제거하세요.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d9424375-fb84-4b7d-9a0f-917265666d00

📥 Commits

Reviewing files that changed from the base of the PR and between 926ad35 and 5e05f7a.

📒 Files selected for processing (47)
  • src/app/(system)/layout.tsx
  • src/app/(system)/system/(dashboard)/page.tsx
  • src/app/(system)/system/approval/page.tsx
  • src/app/(system)/system/billing/page.tsx
  • src/app/(system)/system/companies/page.tsx
  • src/app/(system)/system/monitor/error.tsx
  • src/app/(system)/system/monitor/layout.tsx
  • src/app/(system)/system/monitor/loading.tsx
  • src/app/(system)/system/monitor/page.tsx
  • src/app/(system)/system/notice/error.tsx
  • src/app/(system)/system/notice/layout.tsx
  • src/app/(system)/system/notice/loading.tsx
  • src/app/(system)/system/notice/page.tsx
  • src/app/globals.css
  • src/constants/domain.ts
  • src/features/system/actions.ts
  • src/features/system/components/approval-table.tsx
  • src/features/system/components/company-filter-bar.tsx
  • src/features/system/components/company-table.tsx
  • src/features/system/components/failed-pipeline-table-loader.tsx
  • src/features/system/components/failed-pipeline-table.tsx
  • src/features/system/components/mrr-chart-card.tsx
  • src/features/system/components/mrr-chart-loader.tsx
  • src/features/system/components/mrr-chart.tsx
  • src/features/system/components/notice-compose-card.tsx
  • src/features/system/components/notice-history-card.tsx
  • src/features/system/components/notice-mail-dialog.tsx
  • src/features/system/components/pipeline-retry-button.tsx
  • src/features/system/components/plan-distribution-card.tsx
  • src/features/system/components/plan-donut-chart-loader.tsx
  • src/features/system/components/plan-donut-chart.tsx
  • src/features/system/components/recent-companies-table.tsx
  • src/features/system/components/signup-chart-card.tsx
  • src/features/system/components/signup-chart-loader.tsx
  • src/features/system/components/signup-chart.tsx
  • src/features/system/components/stage-timing-card.tsx
  • src/features/system/components/stat-card.tsx
  • src/features/system/components/status-badge.tsx
  • src/features/system/components/subscription-table.tsx
  • src/features/system/components/system-sidebar.tsx
  • src/features/system/mock/billing.ts
  • src/features/system/mock/companies.ts
  • src/features/system/mock/monitoring.ts
  • src/features/system/mock/notices.ts
  • src/features/system/nav.ts
  • src/features/system/server.ts
  • src/features/system/types.ts

Comment on lines +109 to +137
export async function retryPipelineAction(meetingId: string): Promise<{ success: boolean }> {
if (!isMock) {
// ⚠️ 미구현 — API 스펙 확정 후 BFF 경로로 파이프라인 재처리 요청을 보낸다.
throw new Error("파이프라인 재처리 API가 아직 연결되지 않았습니다.");
}

return { success: findMockFailedItem(meetingId) !== null };
}

/**
* "공지 관리" 공지 발행 — 격리막(CLAUDE.md §Mock 격리막).
*
* ⚠️ 제목·내용이 비면 발행 버튼 자체가 눌리지 않지만(클라이언트 가드), 서버에서도 한 번 더
* 막는다 — 화면 가드는 UX일 뿐 검증이 아니다(CLAUDE.md §권한: 검증은 서버에서).
* ⚠️ 지금은 목이라 **실제로 공지가 나가지 않는다** — 성공만 흉내 낸다(§정직성).
*/
export async function publishNoticeAction(input: {
title: string;
content: string;
target: NoticeTarget;
}): Promise<{ success: boolean }> {
if (!isMock) {
// ⚠️ 미구현 — API 스펙 확정 후 BFF 경로로 공지 발행 요청을 보낸다.
throw new Error("공지 발행 API가 아직 연결되지 않았습니다.");
}

if (!input.title.trim() || !input.content.trim()) return { success: false };

return { success: true };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/features/system/actions.ts --items all
ast-grep outline 'src/app/(system)/layout.tsx' --items all

rg -n -C 4 --glob '*.{ts,tsx}' \
  '\b(retryPipelineAction|publishNoticeAction|requireSystem|requireRole|authorize)\s*\(' \
  src

Repository: Z-Groupware/FRONTEND

Length of output: 4402


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- actions.ts ---'
cat -n src/features/system/actions.ts | sed -n '1,155p'

printf '%s\n' '--- system layout ---'
cat -n 'src/app/(system)/layout.tsx' | sed -n '1,120p'

printf '%s\n' '--- authorization and session symbols ---'
rg -n -C 5 --glob '*.{ts,tsx}' \
  '(getServerSession|getSession|auth\(|requireAuth|requireRole|requireSystem|SYSTEM|role|session|middleware)' \
  src | sed -n '1,260p'

Repository: Z-Groupware/FRONTEND

Length of output: 22107


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- action declarations and directives ---'
rg -n -C 8 --glob '*.{ts,tsx}' \
  '(^|[[:space:]])("use server"|export async function .*Action|retryPipelineAction|publishNoticeAction)' \
  src

printf '%s\n' '--- route callers and route boundaries ---'
rg -n -C 6 --glob '*.{ts,tsx}' \
  '(features/system/actions|retryPipelineAction|publishNoticeAction|SystemLayout|layout)' \
  src/app src/features/system | sed -n '1,300p'

Repository: Z-Groupware/FRONTEND

Length of output: 27974


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- mock configuration ---'
cat -n src/mocks/config.ts | sed -n '1,120p'

printf '%s\n' '--- auth/session/middleware files ---'
git ls-files | rg '(^|/)(middleware|auth|session|permission|security)([^/]*\.(ts|tsx|js|jsx))?$|(^|/)middleware\.(ts|tsx|js|jsx)$' || true

printf '%s\n' '--- system routes ---'
git ls-files 'src/app' | rg '/system/|/\(system\)/' | sed -n '1,160p'

printf '%s\n' '--- project mode and authentication references ---'
rg -n --glob '!src/features/system/actions.ts' --glob '!src/app/(system)/layout.tsx' \
  '(로그인|인증|세션|권한|목업|mock|isMock|httpOnly|cookie|SYSTEM)' \
  README.md CLAUDE.md src package.json 2>/dev/null | sed -n '1,260p'

Repository: Z-Groupware/FRONTEND

Length of output: 24213


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

actions = Path("src/features/system/actions.ts").read_text()
layout = Path("src/app/(system)/layout.tsx").read_text()

action_names = re.findall(r"export async function (\w+Action)\s*\(", actions)
system_actions = [
    "approveCompanyAction",
    "rejectCompanyAction",
    "suspendCompanyAction",
    "unsuspendCompanyAction",
    "sendUnpaidNoticeAction",
    "retryPipelineAction",
    "publishNoticeAction",
]

missing = [name for name in system_actions if name not in action_names]
auth_terms = re.findall(
    r"\b(?:getServerSession|getSession|requireAuth|requireRole|requireSystem|authorize)\b",
    actions,
)
layout_auth_terms = re.findall(
    r"\b(?:getServerSession|getSession|requireAuth|requireRole|requireSystem|authorize)\b",
    layout,
)

print("exported_system_actions:", action_names)
print("missing_expected_actions:", missing)
print("action_auth_guards:", auth_terms)
print("layout_auth_guards:", layout_auth_terms)
print("is_server_action_module:", actions.startswith('"use server";'))
PY

Repository: Z-Groupware/FRONTEND

Length of output: 458


실연동 전에 모든 SYSTEM Server Action에 서버 권한 검사를 추가하세요.

현재 /system/* 레이아웃과 src/features/system/actions.ts의 모든 액션에 인증·SYSTEM 역할 검사가 없습니다. 각 액션에서 세션의 SYSTEM 역할을 먼저 검사하고, 실연동 전에 라우트 경계도 보호하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/actions.ts` around lines 109 - 137, Update every Server
Action in src/features/system/actions.ts, including retryPipelineAction and
publishNoticeAction, to verify an authenticated session with the SYSTEM role
before any existing logic or mock/API branch runs; reject unauthorized callers
consistently. Also protect the /system/* route boundary with the same
authentication and SYSTEM-role requirement before enabling real integration.

Source: Coding guidelines

Comment on lines +125 to +129
export async function publishNoticeAction(input: {
title: string;
content: string;
target: NoticeTarget;
}): Promise<{ success: boolean }> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 --glob '*.{ts,tsx}' \
  'NOTICE_TARGET|NoticeTarget|companyId|recipientCompany|publishNoticeAction' \
  src/constants/domain.ts src/features/system

Repository: Z-Groupware/FRONTEND

Length of output: 29234


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- actions.ts ---'
sed -n '110,145p' src/features/system/actions.ts

printf '%s\n' '--- notice-compose-card.tsx ---'
sed -n '1,120p' src/features/system/components/notice-compose-card.tsx

printf '%s\n' '--- related notice specification references ---'
rg -n -i -C 3 --glob '!node_modules' \
  '특정 기업|공지 작성|발행 대상|공지 발행|recipientCompanyCount|NOTICE_TARGET' \
  . ':!src/features/system/actions.ts' ':!src/features/system/components/notice-compose-card.tsx' \
  | head -250

Repository: Z-Groupware/FRONTEND

Length of output: 15031


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

domain = Path("src/constants/domain.ts").read_text()
actions = Path("src/features/system/actions.ts").read_text()
compose = Path("src/features/system/components/notice-compose-card.tsx").read_text()

target_block = re.search(
    r'export const NOTICE_TARGET = \{(?P<body>.*?)\} as const;',
    domain,
    re.S,
)
action = re.search(
    r'export async function publishNoticeAction\(input: \{(?P<body>.*?)\}\):',
    actions,
    re.S,
)
call = re.search(
    r'publishNoticeAction\(\{(?P<body>.*?)\}\)',
    compose,
    re.S,
)

assert target_block and "SPECIFIC: \"SPECIFIC\"" in target_block.group("body")
assert action and "target: NoticeTarget;" in action.group("body")
assert call and "title" in call.group("body") and "content" in call.group("body") and "target" in call.group("body")
assert not re.search(r'\b(?:companyId|companyIds|recipientCompanies)\b', action.group("body"))
assert not re.search(r'\b(?:companyId|companyIds|recipientCompanies)\b', call.group("body"))

print("SPECIFIC target: present in NOTICE_TARGET and rendered as a selectable option")
print("publishNoticeAction contract: title, content, target only")
print("publishNoticeAction call: title, content, target only")
print("company identifier: absent from both contract and call")
PY

Repository: Z-Groupware/FRONTEND

Length of output: 403


특정 기업 대상 식별자를 요청 계약에 추가하세요.

NOTICE_TARGET.SPECIFIC을 선택할 수 있지만 publishNoticeAction에는 companyId 또는 수신 기업 목록이 없습니다. SPECIFIC 대상이면 식별자를 필수로 받고, 서버에서 대상 유형과 기업 소유 상태를 검증하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/actions.ts` around lines 125 - 129, Update
publishNoticeAction to accept a required company identifier or recipient-company
list when target is NOTICE_TARGET.SPECIFIC, while keeping it absent for other
target types. Validate on the server that the identifier is present and that
each referenced company is owned or authorized by the requesting user before
publishing.

Comment on lines +135 to +137
if (!input.title.trim() || !input.content.trim()) return { success: false };

return { success: true };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 --glob '*.{ts,tsx}' \
  '\b(publishNoticeAction|getNoticeHistory|MOCK_NOTICE_HISTORY|revalidatePath)\b' \
  src

Repository: Z-Groupware/FRONTEND

Length of output: 13026


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- actions.ts ---'
cat -n src/features/system/actions.ts | sed -n '115,150p'

printf '%s\n' '--- mock/notices.ts ---'
cat -n src/features/system/mock/notices.ts

printf '%s\n' '--- system types and server getter ---'
rg -n -C 8 'interface NoticeHistoryItem|type NoticeHistoryItem|getNoticeHistory|MOCK_NOTICE_HISTORY' \
  src/features/system

Repository: Z-Groupware/FRONTEND

Length of output: 6041


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path("src/features/system/actions.ts")
lines = p.read_text().splitlines()
for i in range(124, 141):
    print(f"{i+1:4}: {lines[i]}")
PY

printf '%s\n' '--- notice mock ---'
cat -n src/features/system/mock/notices.ts

printf '%s\n' '--- notice type/getter references ---'
rg -n -C 8 'NoticeHistoryItem|MOCK_NOTICE_HISTORY|getNoticeHistory' src/features/system

Repository: Z-Groupware/FRONTEND

Length of output: 731


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- mock mutation patterns ---'
rg -n -C 10 'export function setMock|\.unshift\(|\.push\(|revalidatePath\(' src/features/system

printf '%s\n' '--- notice history rendering ---'
cat -n src/features/system/components/notice-history-card.tsx | sed -n '1,220p'

printf '%s\n' '--- action imports and mock config ---'
cat -n src/features/system/actions.ts | sed -n '1,25p'
cat -n src/mocks/config.ts

Repository: Z-Groupware/FRONTEND

Length of output: 12823


공지 발행 성공 시 이력과 경로를 갱신하세요.
유효한 입력이면 MOCK_NOTICE_HISTORY에 NoticeHistoryItem을 추가하고 revalidatePath("/system/notice")를 호출해야 합니다. 현재 getNoticeHistory()는 배열만 반환하므로 새 공지가 이력에 표시되지 않습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/actions.ts` around lines 135 - 137, Update the successful
submission path in the action handling the shown input validation to append a
NoticeHistoryItem to MOCK_NOTICE_HISTORY and call
revalidatePath("/system/notice") before returning success. Preserve the existing
failure response for blank title or content, and ensure getNoticeHistory() can
reflect the newly added notice.

Source: Coding guidelines

Comment on lines 82 to 88
<TableRow className={cn(HEADER_HEIGHT_CLASS, "hover:bg-transparent")}>
<TableHead className="pl-6">회사명</TableHead>
<TableHead>대표자</TableHead>
<TableHead>담당자 이메일</TableHead>
<TableHead>규모</TableHead>
<TableHead className="pr-6">신청일</TableHead>
<TableHead className="pl-4 text-xs">회사명</TableHead>
<TableHead className="text-center text-xs">대표자</TableHead>
<TableHead className="text-center text-xs">담당자 이메일</TableHead>
<TableHead className="text-center text-xs">규모</TableHead>
<TableHead className="pr-4 text-center text-xs">신청일</TableHead>
</TableRow>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- candidate files ---'
git ls-files | grep -E '(^|/)(table\.tsx|approval-table\.tsx|recent-companies-table\.tsx)$'
printf '%s\n' '--- table component ---'
table_file=$(git ls-files | grep -E '(^|/)table\.tsx$' | head -n 1)
cat -n "$table_file" | sed -n '55,90p'
printf '%s\n' '--- approval table ---'
approval_file=$(git ls-files | grep -E '(^|/)approval-table\.tsx$' | head -n 1)
cat -n "$approval_file" | sed -n '1,115p'
printf '%s\n' '--- recent companies table ---'
recent_file=$(git ls-files | grep -E '(^|/)recent-companies-table\.tsx$' | head -n 1)
cat -n "$recent_file" | sed -n '1,80p'

Repository: Z-Groupware/FRONTEND

Length of output: 9422


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- cn implementation and relevant class composition ---'
rg -n -A12 -B3 'function cn|const cn|export .*cn' src/lib src
printf '%s\n' '--- all header-height declarations and TableHead consumers in system tables ---'
rg -n -C2 'TableHead|HEADER_HEIGHT_(CLASS|PX)|h-\[34px\]|h-10' src/features/system/components src/components/ui/table.tsx
printf '%s\n' '--- read-only structural check ---'
python3 - <<'PY'
from pathlib import Path
import re

table = Path("src/components/ui/table.tsx").read_text()
head_match = re.search(r'function TableHead.*?className={cn\(\s*"([^"]+)"\s*,\s*className', table, re.S)
assert head_match, "TableHead default class list not found"
head_classes = head_match.group(1).split()
print("TableHead default height utilities:", [c for c in head_classes if c.startswith("h-")])

for name in ("src/features/system/components/approval-table.tsx",
             "src/features/system/components/recent-companies-table.tsx"):
    text = Path(name).read_text()
    rows = re.findall(r'<TableRow[^>]*className="([^"]*h-\[[^"]+\][^"]*)"', text)
    heads = re.findall(r'<TableHead(?:\s+className="([^"]*)")?', text)
    print(name)
    print("  row height classes:", [c for row in rows for c in re.findall(r'h-\[[^]]+\]', row)])
    print("  header className values:", heads)
    print("  header overrides:", [c for c in heads if re.search(r'(^|\\s)h-', c)])
PY

Repository: Z-Groupware/FRONTEND

Length of output: 15194


🌐 Web query:

CSS table row height is determined by the maximum height of its table cells, and table-cell height is a minimum height

💡 Result:

The assertion that CSS table row height is determined by the maximum height of its cells and that a cell's height property acts as a minimum height is largely correct according to the CSS specification, though with important technical nuances [1][2][3][4][5]. Row Height Calculation The height of a table row (table-row) is not determined by a single fixed rule but is calculated after the user agent has analyzed all cells within that row [1][2][3][4]. The row's height is defined as the maximum of: 1. The row's own specified height property (if applicable) [1][2][3][4]. 2. The maximum height required by the cells within that row [1][2][3][4]. The "minimum height required by the cells" (often referred to as MIN) depends on the content of those cells and their vertical alignment, functioning similarly to how line box heights are calculated [1][2][3][4]. Table Cell Height Behavior In CSS, setting a height property on a table-cell (td or th) generally behaves as a minimum height constraint rather than a rigid fixed height [1][2][6][5]. * If the content of the cell is taller than the specified height, the cell will expand to accommodate the content [6]. * If the specified height is greater than the height required by the cell's content, the cell box will still be at least as tall as the specified value (depending on the row's overall height calculation) [1][2][4]. Important Nuances * Property Constraints: Historically, the behavior of min-height and max-height on table-related elements (table, tr, td) has been inconsistent or undefined in some CSS specifications [7][6]. Consequently, web developers often achieve reliable minimum height behavior by placing a block-level element (like a div) inside a table cell and applying min-height to that inner element [6]. * Table-Layout: These height calculations occur within the context of the overall table layout algorithm [8]. The automatic table layout algorithm (the default) calculates dimensions based on cell content, whereas a fixed layout algorithm may treat constraints differently depending on the table's overall width and styling [9][8][10]. * Percentage Heights: Percentage values for height on table rows and cells have historically been problematic and may compute to auto in many implementations [4]. Top Results: [1], [2], [4], [6]

Citations:


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re

table_source = Path("src/components/ui/table.tsx").read_text()
default_match = re.search(
    r'function\s+TableHead\b.*?className=\{cn\(\s*"([^"]+)"',
    table_source,
    re.S,
)
assert default_match, "Could not locate TableHead class list"
default_classes = default_match.group(1).split()
assert "h-10" in default_classes
print("TableHead default height: h-10 = 40px")

targets = {
    "src/features/system/components/approval-table.tsx": (
        r'<TableRow\s+className=\{cn\(\s*HEADER_HEIGHT_CLASS',
        5,
    ),
    "src/features/system/components/recent-companies-table.tsx": (
        r'<TableRow\s+className="h-\[34px\]',
        4,
    ),
}
for filename, (row_pattern, expected_heads) in targets.items():
    source = Path(filename).read_text()
    assert re.search(row_pattern, source)
    header_block = re.search(
        r'<TableHeader>.*?<TableRow.*?</TableRow>',
        source,
        re.S,
    ).group(0)
    head_class_values = re.findall(
        r'<TableHead(?:\s+className="([^"]*)")?',
        header_block,
    )
    assert len(head_class_values) == expected_heads
    overrides = [
        value for value in head_class_values
        if re.search(r'(^|\s)h-(?:10|\[[^]]+\])(?:\s|$)', value)
    ]
    print(f"{filename}: row target = 34px; heads = {len(head_class_values)}; height overrides = {overrides}")
    assert not overrides
PY

Repository: Z-Groupware/FRONTEND

Length of output: 285


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re

table_source = Path("src/components/ui/table.tsx").read_text()
default = re.search(
    r'function\s+TableHead\b.*?className=\{cn\(\s*"([^"]+)"',
    table_source,
    re.S,
).group(1)
print("TableHead default height:", [c for c in default.split() if c.startswith("h-")])

for filename, expected_heads in (
    ("src/features/system/components/approval-table.tsx", 5),
    ("src/features/system/components/recent-companies-table.tsx", 4),
):
    source = Path(filename).read_text()
    header = re.search(r"<TableHeader>.*?</TableHeader>", source, re.S).group(0)
    row = re.search(r"<TableRow\b[^>]*>", header).group(0)
    heads = re.findall(
        r"<TableHead(?:\s+className=\"([^\"]*)\")?\s*>",
        header,
    )
    print(filename)
    print("  row declaration:", row)
    print("  header cell count:", len(heads))
    print("  header cell height classes:",
          [c for value in heads for c in re.findall(r"(?:^|\s)(h-[^\s]+)", value)])
    assert len(heads) == expected_heads
    assert "h-[34px]" in row or "HEADER_HEIGHT_CLASS" in row
    assert not any(re.search(r"(?:^|\s)h-", value) for value in heads)
PY

Repository: Z-Groupware/FRONTEND

Length of output: 575


TableHead 높이를 34px로 맞추세요.

공유 TableHead의 기본값 h-10은 40px입니다. 두 표의 TableRow 높이 h-[34px]보다 크므로 헤더 행이 40px 이상이 될 수 있습니다. 두 표의 모든 TableHead에 h-[34px]를 추가하세요. HEADER_HEIGHT_PX는 이미 34로 일치합니다.

📍 Affects 2 files
  • src/features/system/components/approval-table.tsx#L82-L88 (this comment)
  • src/features/system/components/approval-table.tsx#L27-L28
  • src/features/system/components/recent-companies-table.tsx#L30-L36
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/components/approval-table.tsx` around lines 82 - 88, 모든 표
헤더의 TableHead에 h-[34px] 클래스를 추가하세요. approval-table.tsx의 82-88행과 27-28행,
recent-companies-table.tsx의 30-36행에 있는 각 TableHead를 수정해 두 표의 헤더 높이가 34px이 되도록
하세요.

Comment on lines +29 to +43
export function NoticeComposeCard() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [target, setTarget] = useState<NoticeTarget>(NOTICE_TARGET.ALL);
const [isSent, setIsSent] = useState(false);
const [isPending, startTransition] = useTransition();

const canPublish = title.trim().length > 0 && content.trim().length > 0;

const handlePublish = () => {
startTransition(async () => {
const response = await publishNoticeAction({ title, content, target });
if (response.success) setIsSent(true);
});
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

발행 폼을 useActionState와 useFormStatus로 구현하세요.

현재 handlePublish가 Server Action을 직접 호출합니다. isPending은 버튼을 비활성화할 뿐 발행 진행 상태를 사용자에게 표시하지 않습니다.

<form action={...}> 기반으로 전환하세요. useActionState에서 결과를 관리하고 useFormStatus로 발행 중 상태를 표시하세요. 코딩 가이드라인에 따라 “폼은 useActionState와 useFormStatus로 구현하고 pending 상태를 표시”해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/components/notice-compose-card.tsx` around lines 29 - 43,
NoticeComposeCard의 직접적인 handlePublish/startTransition 호출을 useActionState 기반 form
action으로 전환하고, publishNoticeAction의 결과로 성공 상태를 관리하세요. 제출 버튼은 별도 하위 컴포넌트에서
useFormStatus를 사용해 pending 상태를 표시하고 비활성화하며, 폼 제출 시 제목·내용·대상을 FormData로 전달하도록
유지하세요.

Source: Coding guidelines

Comment on lines +36 to +45
const handleRetry = () => {
startTransition(async () => {
const response = await retryPipelineAction(meetingId);
if (response.success) {
setIsDone(true);
toast(`${companyName} 회의 재처리를 요청했어요`);
} else {
toast(`${companyName} 회의를 찾을 수 없어 재처리하지 못했어요`);
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/features/system/actions.ts --items all
rg -n -C 10 'export async function retryPipelineAction|require.*System|assert.*System|role|authorization' \
  src/features/system/actions.ts

Repository: Z-Groupware/FRONTEND

Length of output: 1880


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- action file ---'
cat -n src/features/system/actions.ts | sed -n '1,145p'

printf '%s\n' '--- retry button ---'
cat -n src/features/system/components/pipeline-retry-button.tsx | sed -n '1,120p'

printf '%s\n' '--- authorization patterns ---'
rg -n -C 4 'getServerSession|getSession|auth\\(|require.*(Role|Auth|Permission|System)|SYSTEM|role|authorization|cookies\\(' \
  src app lib 2>/dev/null | head -n 300

printf '%s\n' '--- action references and route files ---'
rg -n -C 3 'retryPipelineAction|features/system|system' src app 2>/dev/null | head -n 300

Repository: Z-Groupware/FRONTEND

Length of output: 7353


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- top-level directories ---'
find . -maxdepth 2 -type d -not -path './.git*' | sort | head -n 120

printf '%s\n' '--- authentication and role references in tracked files ---'
git grep -n -E 'getServerSession|getSession|auth\\(|require.*(Role|Auth|Permission|System)|SYSTEM|role|authorization|cookies\\(' -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.mjs' '*.cjs' 2>/dev/null | head -n 400

printf '%s\n' '--- server actions and retry references ---'
git grep -n -C 4 -E 'retryPipelineAction|^export async function .*Action|use server|middleware|/system' -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.mjs' '*.cjs' 2>/dev/null | head -n 500

printf '%s\n' '--- route files ---'
git ls-files | grep -E '(^|/)(page|layout|route|middleware)\\.(ts|tsx|js|jsx|mjs|cjs)$' | head -n 200

Repository: Z-Groupware/FRONTEND

Length of output: 24517


retryPipelineAction에 서버 측 SYSTEM 권한 검사를 추가하세요.

src/features/system/actions.ts:109-116은 isMock 분기와 meetingId 조회만 수행합니다. isMock 분기 전에 세션의 SYSTEM 역할을 검증하고, 권한이 없으면 작업을 중단하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/components/pipeline-retry-button.tsx` around lines 36 -
45, Update the server-side retryPipelineAction flow to validate the session’s
SYSTEM role before the isMock branch and meetingId lookup. Abort and return the
existing failure shape when the caller lacks SYSTEM permission, while preserving
the current authorized retry behavior.

Source: Coding guidelines

const PlanDonutChart = dynamic(() => import("./plan-donut-chart").then((m) => m.PlanDonutChart), {
ssr: false,
loading: () => <Skeleton className="mx-auto size-[144px] rounded-full" />,
loading: () => <Skeleton className="mx-auto size-[116px] rounded-full" />,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
fd -i 'plan-donut-chart' .

printf '%s\n' '--- outlines ---'
for f in $(fd -i 'plan-donut-chart' .); do
  printf '\n### %s\n' "$f"
  ast-grep outline "$f" || true
done

printf '%s\n' '--- loader ---'
loader="$(fd -i -t f 'plan-donut-chart-loader' . | head -n 1)"
cat -n "$loader"

printf '%s\n' '--- chart ---'
chart="$(fd -i -t f 'plan-donut-chart' . | grep -v 'loader' | head -n 1)"
cat -n "$chart"

printf '%s\n' '--- usages and surrounding layout ---'
rg -n -C 5 'PlanDonutChartLoader|PlanDonutChart' --glob '*.{ts,tsx}' .

Repository: Z-Groupware/FRONTEND

Length of output: 8120


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

loader = Path("src/features/system/components/plan-donut-chart-loader.tsx").read_text()
chart = Path("src/features/system/components/plan-donut-chart.tsx").read_text()
card = Path("src/features/system/components/plan-distribution-card.tsx").read_text()

skeleton = re.search(r'className="[^"]*\bsize-\[(\d+)px\]', loader)
responsive = re.search(r'<ResponsiveContainer\b[^>]*\bheight=\{(\d+)\}', chart)
mount = re.search(
    r'<div\s+className="mt-3">\s*<PlanDonutChartLoader\b[^>]*/>\s*</div>',
    card,
    re.S,
)

assert skeleton, "loading skeleton size was not found"
assert responsive, "ResponsiveContainer height was not found"
assert mount, "loader mount wrapper was not found"

loading_size = int(skeleton.group(1))
rendered_height = int(responsive.group(1))
assert loading_size != rendered_height
assert rendered_height - loading_size == 12
assert "h-" not in mount.group(0) and "min-h-" not in mount.group(0)

print({
    "loading_skeleton_size_px": loading_size,
    "resolved_chart_height_px": rendered_height,
    "height_delta_px": rendered_height - loading_size,
    "parent_reserves_fixed_height": False,
})
PY

Repository: Z-Groupware/FRONTEND

Length of output: 287


로딩 영역의 높이를 차트와 일치시키세요.

loading은 116px을 반환하지만 ResponsiveContainer는 128px 높이를 사용합니다. 부모가 고정 높이를 예약하지 않아 차트 로드 후 범례가 12px 이동합니다. 128px 래퍼 안에서 스켈레톤을 중앙 정렬하거나 높이를 공유 상수로 관리하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/components/plan-donut-chart-loader.tsx` at line 12,
Update the loading renderer in the plan donut chart loader so its reserved area
matches the ResponsiveContainer’s 128px height, centering the existing skeleton
within a 128px wrapper or reusing a shared height constant; preserve the
skeleton’s circular styling.

Comment on lines +30 to +46
// 가장 오래 걸린 단계를 100%로 잡아 나머지를 상대 길이로 그린다.
const maxSeconds = Math.max(...timings.map((timing) => timing.avgSeconds));

return (
<section className="border-border bg-card rounded-xl border p-5">
<h2 className="text-foreground text-sm font-semibold">단계별 평균 소요 시간</h2>

<div className="mt-4 flex flex-col gap-3">
{timings.map((timing) => (
<div key={timing.stage} className="flex items-center gap-3">
<span className="text-muted-foreground w-20 shrink-0 text-xs">
{PIPELINE_STAGE_LABEL[timing.stage]}
</span>
<div className="bg-muted h-4 flex-1 overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-[width] duration-[250ms] ${STAGE_BAR_CLASS[timing.stage]}`}
style={{ width: `${(timing.avgSeconds / maxSeconds) * 100}%` }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

0초 데이터에서 막대 너비를 0%로 처리하세요.

모든 단계의 avgSeconds가 0이면 maxSeconds는 0입니다. Line 46은 NaN%를 생성하므로 브라우저가 막대 너비를 적용하지 못합니다. 분모가 0이면 너비를 0%로 설정하세요.

수정 예시
 const maxSeconds = Math.max(...timings.map((timing) => timing.avgSeconds));
 
+const getWidthPercent = (seconds: number) =>
+  maxSeconds > 0 ? (seconds / maxSeconds) * 100 : 0;
+
 // ...
-                style={{ width: `${(timing.avgSeconds / maxSeconds) * 100}%` }}
+                style={{ width: `${getWidthPercent(timing.avgSeconds)}%` }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 가장 오래 걸린 단계를 100%로 잡아 나머지를 상대 길이로 그린다.
const maxSeconds = Math.max(...timings.map((timing) => timing.avgSeconds));
return (
<section className="border-border bg-card rounded-xl border p-5">
<h2 className="text-foreground text-sm font-semibold">단계별 평균 소요 시간</h2>
<div className="mt-4 flex flex-col gap-3">
{timings.map((timing) => (
<div key={timing.stage} className="flex items-center gap-3">
<span className="text-muted-foreground w-20 shrink-0 text-xs">
{PIPELINE_STAGE_LABEL[timing.stage]}
</span>
<div className="bg-muted h-4 flex-1 overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-[width] duration-[250ms] ${STAGE_BAR_CLASS[timing.stage]}`}
style={{ width: `${(timing.avgSeconds / maxSeconds) * 100}%` }}
// 가장 오래 걸린 단계를 100%로 잡아 나머지를 상대 길이로 그린다.
const maxSeconds = Math.max(...timings.map((timing) => timing.avgSeconds));
const getWidthPercent = (seconds: number) =>
maxSeconds > 0 ? (seconds / maxSeconds) * 100 : 0;
return (
<section className="border-border bg-card rounded-xl border p-5">
<h2 className="text-foreground text-sm font-semibold">단계별 평균 소요 시간</h2>
<div className="mt-4 flex flex-col gap-3">
{timings.map((timing) => (
<div key={timing.stage} className="flex items-center gap-3">
<span className="text-muted-foreground w-20 shrink-0 text-xs">
{PIPELINE_STAGE_LABEL[timing.stage]}
</span>
<div className="bg-muted h-4 flex-1 overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-[width] duration-[250ms] ${STAGE_BAR_CLASS[timing.stage]}`}
style={{ width: `${getWidthPercent(timing.avgSeconds)}%` }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/components/stage-timing-card.tsx` around lines 30 - 46,
Update the bar width calculation in the timings map within the stage-timing
component so that when maxSeconds is 0, the width is explicitly 0%; otherwise
preserve the existing relative percentage calculation using timing.avgSeconds
and maxSeconds.

import { PAYMENT_STATUS, PAYMENT_STATUS_LABEL, PLAN } from "@/constants/domain";
import { cn } from "@/lib/utils";

import { sendUnpaidNoticeAction } from "../actions";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

서버 액션에서 SYSTEM 권한과 대상 범위를 검사하세요.

src/features/system/actions.ts Line 87-99의 sendUnpaidNoticeAction은 isMock과 기업 존재 여부만 검사합니다. 이 액션은 ownerEmail도 반환합니다.

클라이언트 라우트 보호에 의존하지 마세요. 서버 액션에서 SYSTEM 역할과 대상 기업 접근 범위를 검사하세요. 사용하지 않는 ownerEmail 반환도 제거하세요.

As per coding guidelines, 모든 권한 판정은 서버에서 수행하고 역할 권한과 리소스 소유권을 별도로 검사해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/components/subscription-table.tsx` at line 18, Update
sendUnpaidNoticeAction in the server actions to independently validate the
caller’s SYSTEM role and access to the targeted company, rather than relying on
client route protection; retain the existing mock/company checks as applicable,
and remove the unused ownerEmail value from the action’s return data.

Source: Coding guidelines

Comment on lines +72 to +80
startTransition(async () => {
const response = await sendUnpaidNoticeAction(companyId);
if (response.success) {
toast(`${target.companyName} 담당자에게 안내 메일을 발송했어요`);
} else {
toast(`${target.companyName} 정보를 찾을 수 없어 발송하지 못했어요`);
}
setNoticeTarget(null);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

서버 액션 예외를 처리하세요.

sendUnpaidNoticeAction은 라이브 설정에서 예외를 던집니다. 현재 await 호출에는 try/catch가 없습니다.

액션이 실패하면 실패 토스트가 표시되지 않고 오류 경계로 전파될 수 있습니다. 예외를 처리하고 실패 결과를 사용자에게 표시하세요.

수정 예시
 startTransition(async () => {
-  const response = await sendUnpaidNoticeAction(companyId);
-  if (response.success) {
-    toast(`${target.companyName} 담당자에게 안내 메일을 발송했어요`);
-  } else {
-    toast(`${target.companyName} 정보를 찾을 수 없어 발송하지 못했어요`);
+  try {
+    const response = await sendUnpaidNoticeAction(companyId);
+    if (response.success) {
+      toast(`${target.companyName} 담당자에게 안내 메일을 발송했어요`);
+    } else {
+      toast(`${target.companyName} 정보를 찾을 수 없어 발송하지 못했어요`);
+    }
+  } catch {
+    toast(`${target.companyName} 안내 메일을 발송하지 못했어요`);
+  } finally {
+    setNoticeTarget(null);
   }
-  setNoticeTarget(null);
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
startTransition(async () => {
const response = await sendUnpaidNoticeAction(companyId);
if (response.success) {
toast(`${target.companyName} 담당자에게 안내 메일을 발송했어요`);
} else {
toast(`${target.companyName} 정보를 찾을 수 없어 발송하지 못했어요`);
}
setNoticeTarget(null);
});
startTransition(async () => {
try {
const response = await sendUnpaidNoticeAction(companyId);
if (response.success) {
toast(`${target.companyName} 담당자에게 안내 메일을 발송했어요`);
} else {
toast(`${target.companyName} 정보를 찾을 수 없어 발송하지 못했어요`);
}
} catch {
toast(`${target.companyName} 안내 메일을 발송하지 못했어요`);
} finally {
setNoticeTarget(null);
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/system/components/subscription-table.tsx` around lines 72 - 80,
sendUnpaidNoticeAction 호출을 감싸는 try/catch를 startTransition 콜백에 추가해 서버 액션 예외가 오류
경계로 전파되지 않도록 하세요. 성공 응답은 기존 토스트를 유지하고, 반환된 실패 응답과 예외 발생 모두 target.companyName을
포함한 실패 토스트를 표시하며, 처리 후에는 setNoticeTarget(null)이 실행되도록 하세요.

@hyun030514 hyun030514 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다.

@hyun030514
hyun030514 merged commit 10b4127 into develop Aug 4, 2026
2 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Z Aug 4, 2026
@hyun030514 hyun030514 changed the title [FEAT] 시스템 공지 관리 화면 (공지 작성·발행 이력) #72 feat: 시스템 공지 관리 화면 (공지 작성·발행 이력) #72 Aug 13, 2026
@hyun030514 hyun030514 changed the title feat: 시스템 공지 관리 화면 (공지 작성·발행 이력) #72 [FEAT] 시스템 공지 관리 화면 (공지 작성·발행 이력) #72 Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[FEAT] 시스템 공지 관리 화면 (공지 작성·발행 이력)

2 participants