Repository navigation
[FEAT] 시스템 공지 관리 화면 (공지 작성·발행 이력) #72 - #73
Conversation
📝 WalkthroughWalkthrough시스템 모니터링과 공지 관리 화면을 추가했습니다. 모니터링 실패 항목 재처리와 공지 발행 목 액션을 연결했습니다. 공통 상태 표현, 운영 화면 레이아웃, 테이블과 차트 스타일을 조정했습니다. Changes시스템 운영 기능
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: 성공 또는 실패 결과
sequenceDiagram
participant 운영자
participant NoticeComposeCard
participant publishNoticeAction
participant SystemNoticePage
participant NoticeHistoryCard
운영자->>NoticeComposeCard: 제목, 내용, 대상 입력
NoticeComposeCard->>publishNoticeAction: 공지 발행 요청
publishNoticeAction-->>NoticeComposeCard: 발행 결과 반환
NoticeComposeCard-->>운영자: 발행 완료 안내 표시
SystemNoticePage->>NoticeHistoryCard: 공지 이력 전달
NoticeHistoryCard-->>운영자: 제목, 대상, 발송 수 표시
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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 valueProps 타입을
interface로 분리하세요.세 컴포넌트가 props 객체 타입을 인라인으로 선언합니다. 각 파일에
ErrorProps,SystemNoticeLayoutProps,NoticeHistoryCardProps인터페이스를 선언하고 함수 파라미터에 사용하세요.
src/app/(system)/system/notice/error.tsx#L5-L5:resetprops 인터페이스를 선언하세요.src/app/(system)/system/notice/layout.tsx#L6-L6:childrenprops 인터페이스를 선언하세요.src/features/system/components/notice-history-card.tsx#L6-L6:itemsprops 인터페이스를 선언하세요.코딩 가이드라인에 따라 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 valueprops 타입을 명시적 인터페이스로 분리하세요.
세 컴포넌트가 인라인 객체 타입으로 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
📒 Files selected for processing (47)
src/app/(system)/layout.tsxsrc/app/(system)/system/(dashboard)/page.tsxsrc/app/(system)/system/approval/page.tsxsrc/app/(system)/system/billing/page.tsxsrc/app/(system)/system/companies/page.tsxsrc/app/(system)/system/monitor/error.tsxsrc/app/(system)/system/monitor/layout.tsxsrc/app/(system)/system/monitor/loading.tsxsrc/app/(system)/system/monitor/page.tsxsrc/app/(system)/system/notice/error.tsxsrc/app/(system)/system/notice/layout.tsxsrc/app/(system)/system/notice/loading.tsxsrc/app/(system)/system/notice/page.tsxsrc/app/globals.csssrc/constants/domain.tssrc/features/system/actions.tssrc/features/system/components/approval-table.tsxsrc/features/system/components/company-filter-bar.tsxsrc/features/system/components/company-table.tsxsrc/features/system/components/failed-pipeline-table-loader.tsxsrc/features/system/components/failed-pipeline-table.tsxsrc/features/system/components/mrr-chart-card.tsxsrc/features/system/components/mrr-chart-loader.tsxsrc/features/system/components/mrr-chart.tsxsrc/features/system/components/notice-compose-card.tsxsrc/features/system/components/notice-history-card.tsxsrc/features/system/components/notice-mail-dialog.tsxsrc/features/system/components/pipeline-retry-button.tsxsrc/features/system/components/plan-distribution-card.tsxsrc/features/system/components/plan-donut-chart-loader.tsxsrc/features/system/components/plan-donut-chart.tsxsrc/features/system/components/recent-companies-table.tsxsrc/features/system/components/signup-chart-card.tsxsrc/features/system/components/signup-chart-loader.tsxsrc/features/system/components/signup-chart.tsxsrc/features/system/components/stage-timing-card.tsxsrc/features/system/components/stat-card.tsxsrc/features/system/components/status-badge.tsxsrc/features/system/components/subscription-table.tsxsrc/features/system/components/system-sidebar.tsxsrc/features/system/mock/billing.tssrc/features/system/mock/companies.tssrc/features/system/mock/monitoring.tssrc/features/system/mock/notices.tssrc/features/system/nav.tssrc/features/system/server.tssrc/features/system/types.ts
| 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 }; |
There was a problem hiding this comment.
🔒 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*\(' \
srcRepository: 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";'))
PYRepository: 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
| export async function publishNoticeAction(input: { | ||
| title: string; | ||
| content: string; | ||
| target: NoticeTarget; | ||
| }): Promise<{ success: boolean }> { |
There was a problem hiding this comment.
🗄️ 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/systemRepository: 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 -250Repository: 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")
PYRepository: 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.
| if (!input.title.trim() || !input.content.trim()) return { success: false }; | ||
|
|
||
| return { success: true }; |
There was a problem hiding this comment.
🗄️ 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' \
srcRepository: 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/systemRepository: 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/systemRepository: 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.tsRepository: 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
| <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> |
There was a problem hiding this comment.
🎯 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)])
PYRepository: 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:
- 1: https://www.w3.org/TR/CSS22/tables.html
- 2: https://www.w3.org/TR/2009/CR-CSS2-20090908/tables.html
- 3: https://www.w3.org/TR/2008/REC-CSS2-20080411/tables.html
- 4: https://www.w3.org/TR/2006/WD-CSS21-20060411/tables.html
- 5: http://lists.w3.org/Archives/Public/www-style/2012Nov/0474.html
- 6: https://stackoverflow.com/questions/19432092/can-i-use-a-min-height-for-table-tr-or-td
- 7: https://lists.w3.org/Archives/Public/www-style/2010Jun/0443.html
- 8: https://www.w3.org/TR/CSS2/tables
- 9: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/table-layout
- 10: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Styling_basics/Tables
🏁 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
PYRepository: 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)
PYRepository: 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-L28src/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이 되도록
하세요.
| 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); | ||
| }); | ||
| }; |
There was a problem hiding this comment.
🎯 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
| const handleRetry = () => { | ||
| startTransition(async () => { | ||
| const response = await retryPipelineAction(meetingId); | ||
| if (response.success) { | ||
| setIsDone(true); | ||
| toast(`${companyName} 회의 재처리를 요청했어요`); | ||
| } else { | ||
| toast(`${companyName} 회의를 찾을 수 없어 재처리하지 못했어요`); | ||
| } | ||
| }); |
There was a problem hiding this comment.
🔒 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.tsRepository: 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 300Repository: 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 200Repository: 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" />, |
There was a problem hiding this comment.
🎯 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,
})
PYRepository: 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.
| // 가장 오래 걸린 단계를 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}%` }} |
There was a problem hiding this comment.
🎯 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.
| // 가장 오래 걸린 단계를 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"; |
There was a problem hiding this comment.
🔒 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
| startTransition(async () => { | ||
| const response = await sendUnpaidNoticeAction(companyId); | ||
| if (response.success) { | ||
| toast(`${target.companyName} 담당자에게 안내 메일을 발송했어요`); | ||
| } else { | ||
| toast(`${target.companyName} 정보를 찾을 수 없어 발송하지 못했어요`); | ||
| } | ||
| setNoticeTarget(null); | ||
| }); |
There was a problem hiding this comment.
🩺 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.
| 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)이 실행되도록 하세요.
📋 PR 타입
🗂 작업 영역
🙋 관련 역할
📝 작업 내용
/system/notice화면 신규 — 좌 공지 작성 폼 + 우 발행 이력 (1440 2컬럼)NOTICE_TARGET상수/라벨 ·NoticeHistoryItem타입 ·mock/notices.ts·getNoticeHistory·publishNoticeActionisReady) · 아이콘Megaphone✅ 작업 체크리스트
기본
feature/system-notice#{이슈번호}, basedevelop)Closes #에 이슈 번호 기입console·주석 코드 제거Z 필수
getNoticeHistory·publishNoticeAction목/미구현 주석 명시, 실제 발송 안 됨품질
label htmlFor, 버튼 이름, SelectariaButton/Input/Label/SelectUI 프리미티브 재사용🔗 연관 이슈
Closes #72
📸 스크린샷 (선택)
💬 리뷰 포인트
SelectValuerender-prop으로 라벨 표시 — base-ui가 팝업 열기 전 값(ALL)을 그대로 보이는 기존 앱 공통 quirk를 이 화면에서만 우회📝 추가 메모
Summary by CodeRabbit