Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🔺 세모-STORYBOOK |
Walkthrough다중 파일 기반의 문제 스캔 그룹 생성 및 편집 흐름이 도입되었습니다. Step1에서 여러 파일을 업로드해 그룹을 생성하고 그룹 요약을 통해 각 스캔을 개별 편집·검토한 뒤 일괄 생성API로 문제를 등록하는 구조로 변경되었습니다. 기존 Step2–4와 관련 훅/스타일은 제거되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant User as User
participant Client as Client(UI)
participant API as API Server
participant Storage as sessionStorage
participant BulkAPI as Bulk Create API
User->>Client: 여러 파일 선택 (Step1)
Client->>API: POST /problem-scans/groups (files[])
API-->>Client: { groupId, scanIds[] }
Client->>Storage: saveWrongCreateGroupContext(groupId + items)
Client->>Client: navigate /wrong/create/scans?group=groupId
Client->>API: GET /problem-scans/groups/{groupId}/summary
API-->>Client: group summary (summaries...)
loop 사용자 편집 (각 scan)
User->>Client: 상세 열기 → /wrong/scans/{id}?group=groupId
Client->>Storage: readWrongCreateGroupContext(groupId)
Client->>Client: edit subject/unit/types/answer
Client->>Storage: persistGroupItem(updatedItem)
end
User->>Client: 완료 → 일괄 생성 요청 준비
Client->>BulkAPI: POST /problems/bulk (ProblemCreateRequest[])
BulkAPI-->>Client: { problems[] }
Client->>User: 등록 완료 / redirect
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 17
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/shared/components/app-bar/utils/app-bar-routing.ts (1)
39-46:⚠️ Potential issue | 🟡 Minor
/wrong/scans가 상세 라우트로 오인식될 수 있습니다.Line 39의 분기 때문에
/wrong/scans경로가{ type: "detail", id: "scans" }로 매칭됩니다.scans가 예약 세그먼트가 된 만큼 예외 가드를 추가해 app-bar 상태 오인을 막는 게 안전합니다.개선 예시
// /wrong/[id] if (segments.length === 2) { + if (segments[1] === "scans") return { type: "none" as const }; return { type: "detail" as const, id: segments[1]! }; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/shared/components/app-bar/utils/app-bar-routing.ts` around lines 39 - 46, The route parser currently treats any two-segment path as a generic detail (if segments.length === 2) which causes "/wrong/scans" to be misclassified as { type: "detail", id: "scans" }; update the guard in that branch to exclude reserved segment names (at least "scans") before returning a detail type. Specifically, in the branch checking segments.length === 2 (and the same code path that constructs { type: "detail" as const, id: segments[1]! }), add a conditional that skips returning a detail when segments[1] === "scans" (or other reserved tokens), falling through so the subsequent scans-specific branch (segments.length === 3 && segments[1] === "scans") can match correctly. Ensure tests or callers using the function that depends on segments and the returned type still behave as expected.
🧹 Nitpick comments (8)
apps/web/src/shared/apis/problem-create/problem-create-types.ts (1)
20-22: Bulk 응답 컬렉션을 불변 타입으로 고정해 주세요.현재
problems가 가변 배열이라 후속 코드에서 실수로 mutate될 수 있습니다. 응답 타입을readonly로 잠가두면 캐시 안정성이 좋아집니다.🔧 제안 변경
export type ProblemBulkCreateResponse = { - problems: ProblemCreateResponse[]; + readonly problems: ReadonlyArray<ProblemCreateResponse>; };As per coding guidelines, API 리뷰 시 "타입 안정성 확보(응답 제네릭, 불변 데이터)"를 확인해야 합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/shared/apis/problem-create/problem-create-types.ts` around lines 20 - 22, Change the mutable `problems: ProblemCreateResponse[]` in the `ProblemBulkCreateResponse` type to an immutable collection so callers can't accidentally mutate cached responses; update the field to use a readonly type such as `readonly ProblemCreateResponse[]` (or `ReadonlyArray<ProblemCreateResponse>`) in `ProblemBulkCreateResponse` and adjust any call sites that rely on mutation to avoid in-place changes.apps/web/src/shared/components/chip/chip.tsx (1)
23-41:as="span"모드에서 버튼 전용 props를 타입으로 차단해 주세요.지금은
as="span"이어도onClick,disabled,type전달이 가능하지만 렌더 시 무시됩니다. 호출부에서 인터랙션 가능 컴포넌트로 오해하기 쉬워서,ChipProps를 판별 유니온으로 분리하는 편이 안전합니다.As per coding guidelines,
**/src/shared/components/**에서는 "TypeScript 타입 정의의 정확성"과 "일관된 API 디자인과 명명 규칙"을 우선해야 합니다.Also applies to: 65-82
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/shared/components/chip/chip.tsx` around lines 23 - 41, Split the existing ChipProps into a discriminated union based on the as prop so that interactive button props are only allowed when as is a button: replace the current single ChipProps (which mixes Pick<ButtonHTMLAttributes, "disabled" | "onClick" | "type">) with two variants (e.g., ButtonChipProps having as?: "button" | undefined plus onClick/disabled/type, and SpanChipProps having as: "span" and no onClick/disabled/type), keep shared fields (label, icon, tone, etc.) in a common base, and update usages of ChipProps/Chip component to use the new union; ensure the discriminant is the as property so TypeScript will block passing onClick/disabled/type when as="span".apps/web/src/shared/components/app-bar/app-bar-gate.tsx (1)
9-15: shared 계층에 feature 의존성이 직접 들어와 결합도가 높아졌습니다.Line 9처럼
@/app/wrong/create/...를 shared 컴포넌트에서 직접 참조하면, app-bar의 재사용성과 계층 경계가 약해집니다.getWrongCreateGroupContext주입은 상위 레이어(페이지/레이아웃)에서 연결하고 shared 쪽은 인터페이스만 받도록 분리하는 편이 유지보수에 유리합니다.As per coding guidelines "도메인, 페이지, 특정 피처 의존성 최소화(결합도 낮게 유지)".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/shared/components/app-bar/app-bar-gate.tsx` around lines 9 - 15, AppBarGate currently imports the feature-specific readWrongCreateGroupContext (from "@/app/wrong/create/...") which increases coupling; instead modify AppBarGate to accept the dependency from its parent: remove the direct import and have AppBarGate accept a prop (e.g., getWrongCreateGroupContext) and pass that into useAppBar; update callers (pages/layouts) to import readWrongCreateGroupContext and inject it when rendering AppBarGate so the shared component only depends on the interface used by useAppBar and not the concrete feature implementation.apps/web/src/app/wrong/scans/[id]/components/scan-detail-hero.tsx (1)
35-42: 접근성: 편집 버튼에aria-label추가 권장버튼 내부에 아이콘과 텍스트가 있지만, 스크린 리더 사용자를 위해 명시적인
aria-label을 추가하면 더 좋습니다.♻️ 제안
<button type="button" className={s.editButton} onClick={onEditClick} + aria-label="단원 수정하기" >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/app/wrong/scans/`[id]/components/scan-detail-hero.tsx around lines 35 - 42, The edit button rendered in the ScanDetailHero component lacks an explicit aria-label; update the <button> that uses className s.editButton, onClick onEditClick and contains <Icon name="edit-scan"> to include a descriptive aria-label (e.g., aria-label="단원 수정하기" or a localized equivalent) so screen readers announce the control clearly while keeping visible text unchanged.apps/web/src/app/wrong/create/page.tsx (1)
30-39: 사용하지 않는pathname제거 가능
useWrongCreateRoute에서pathname을 반환하지만 이 페이지에서는 사용하지 않습니다. 불필요한 destructuring을 제거하면 코드가 더 깔끔해집니다.참고: Context snippet에
pathname이 반환값에 포함되어 있으나 실제 훅 코드(Line 46-54)에서는 반환하고 있습니다. 페이지에서 사용하지 않으므로 destructure에서 제외 가능합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/app/wrong/create/page.tsx` around lines 30 - 39, The component WrongCreatePage unnecessarily destructures pathname from useWrongCreateRoute; remove pathname from the destructuring so the line becomes const { router, params, scanIds, groupId, goStep } = useWrongCreateRoute(); leaving the rest (copy = WRONG_CREATE_STEP_COPY[1] and the call to useStep1SummaryTransition) unchanged to keep behavior identical while cleaning up the unused variable.apps/web/src/shared/components/app-bar/app-bar.tsx (1)
207-233: 드롭다운 메뉴 접근성 개선 필요현재 드롭다운에 키보드 탐색(Escape로 닫기, 화살표 키 이동)과 외부 클릭 시 닫기 기능이 없습니다. 접근성 가이드라인 준수를 위해 개선이 필요합니다.
♻️ 개선 방향
- Escape 키로 닫기:
onKeyDown핸들러 추가- 외부 클릭 시 닫기:
useClickOutside훅 또는 overlay 활용- 포커스 관리: 열릴 때 첫 아이템으로 포커스 이동
// 예시: Escape 키 처리 useEffect(() => { if (!props.titleMenu.isOpen) return; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") props.titleMenu.onToggle(); }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [props.titleMenu.isOpen, props.titleMenu.onToggle]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/shared/components/app-bar/app-bar.tsx` around lines 207 - 233, The dropdown rendered when props.titleMenu.isOpen lacks keyboard and outside-click handling; add an effect and handlers to close on Escape and close on outside clicks, plus move focus to the first item when opened. Specifically, in the component that renders the div with className s.titleDropdown and buttons with s.titleDropdownItem, add a useEffect that listens for "keydown" (handle Escape to call props.titleMenu.onToggle) and for clicks outside the s.titleDropdown element (use a useClickOutside hook or a document click listener that calls props.titleMenu.onToggle), and when props.titleMenu.isOpen becomes true programmatically set focus to the first item (e.g., the first button from props.titleMenu.items) so arrow/keyboard navigation starts there; ensure you clean up listeners on unmount and when isOpen changes and preserve existing onClick handlers on each item.apps/web/src/app/wrong/create/hooks/use-wrong-create-route.ts (1)
22-44:goStep함수useCallback래핑 고려
goStep이 매 렌더마다 새로 생성되어, 이를 의존성으로 사용하는 하위 컴포넌트/훅에서 불필요한 재실행이 발생할 수 있습니다.♻️ 제안
+import { useCallback, useMemo } from "react"; ... - const goStep = (nextStep: number, extra?: Record<string, string | null>) => { + const goStep = useCallback((nextStep: number, extra?: Record<string, string | null>) => { const safe = clamp(nextStep, 1, Number.MAX_SAFE_INTEGER); ... router.replace(`${pathname}?${nextQuery}`, { scroll: false }); - }; + }, [spString, pathname, router]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/app/wrong/create/hooks/use-wrong-create-route.ts` around lines 22 - 44, The goStep function is recreated on every render causing unnecessary downstream updates; wrap goStep with React's useCallback to memoize it and list its external dependencies (spString, pathname, router, and clamp if it's not a stable import) in the dependency array so it only changes when those values change; ensure useCallback is imported and keep the same signature (nextStep, extra?) and behavior inside the memoized function.apps/web/src/app/wrong/create/components/steps/step-1.tsx (1)
35-41: 파일 검증 실패 시 사용자 피드백 개선 고려현재 첫 번째 오류 파일에서
window.alert로 중단됩니다. 여러 파일 중 어떤 파일이 문제인지 명확히 안내하면 UX가 향상됩니다.💡 제안
for (const file of files) { const error = validateImageFile(file); if (error) { - window.alert(error); + window.alert(`${file.name}: ${error}`); return; } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/app/wrong/create/components/steps/step-1.tsx` around lines 35 - 41, The current loop over files calls validateImageFile(file) and immediately calls window.alert(error) and returns on the first error, which hides which file(s) failed; instead, collect validation results for all files (use the existing files variable and validateImageFile) and build a user-friendly aggregated message that includes each failing file name and its specific error, then present that consolidated feedback (e.g., via component state and a visible error list or a single alert with all entries) rather than returning on the first error; update the handler that currently uses window.alert to display the aggregated message or render the error list in the component UI.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/src/app/wrong/create/hooks/step1/use-step1-summary-transition.ts`:
- Around line 175-187: Handle group-level failure from
problemScanApi.getGroupSummary before inspecting individual summaries: after
fetching groupSummary (from problemScanApi.getGroupSummary) check if
groupSummary.status === "FAILED" and if so call toastError with an appropriate
message and call resetFlow (similar to per-summary failure handling), then
return; keep existing flowKeyRef.current and isMountedRef.current guards intact
and only proceed to check summaries/isFailedSummary when group-level status is
not FAILED.
- Around line 112-163: createGroupContext currently builds items only from
payloads, which drops summaries whose buildPayloadFromSummary returned null and
prevents those scans from advancing; change createGroupContext (used with
createWrongCreateGroupId, saveWrongCreateGroupContext, router.push to
ROUTES.WRONG.CREATE_SCANS) to iterate over summaries instead, lookup payloads by
scanId, and always include an item per summary: if payload is present use its
fields, otherwise set finalUnitId/finalTypeIds/answer* to null and derive
subjectName/unitName/imageUrl from the summary so the scan proceeds to
/wrong/create/scans for manual classification; ensure typeNames handles null
finalTypeIds and needsReview is set from summary.classification.needsReview.
In `@apps/web/src/app/wrong/create/scans/page.tsx`:
- Around line 28-31: The effect exits too early when groupId is missing so users
get a permanent blank (page returns null at the bottom); update the useEffect in
page.tsx to treat missing group or missing groupId as a redirect condition
(i.e., when !groupId || !group call router.replace(ROUTES.WRONG.ROOT)
immediately) and remove the early-return that leaves the component rendering
null; also ensure the render path shows a loading/fallback while routing (or
immediately navigates) instead of returning null so users can recover.
- Around line 61-65: handleNext currently always uses items[0] and enables the
CTA even when the current tab’s filtered list is empty; change it to use the
filtered list (visibleItems) instead: compute const firstVisible =
visibleItems[0], return early if !firstVisible, and call
router.push(buildProblemHref(firstVisible)); also update the equivalent handler
at the other location (the one referenced around lines 149-156) to use
visibleItems and to only enable the button when visibleItems.length > 0 so the
CTA matches the tab’s visible items.
In `@apps/web/src/app/wrong/create/utils/group-context.ts`:
- Around line 50-60: The function readWrongCreateGroupContext currently casts
JSON.parse(raw) directly to WrongCreateGroupContext which can crash later;
update readWrongCreateGroupContext to validate the parsed object shape before
returning: after parsing, check required top-level fields (e.g. id is a string,
createdAt is a valid date/string, and items is an array) and ensure items
elements have the minimal shape your UI expects (e.g. item.id and any fields
used by items.filter/render). If any check fails, return null. Use
buildStorageKey and the existing try/catch but replace the unchecked cast with
this defensive validation so downstream calls like items.filter are safe.
In `@apps/web/src/app/wrong/create/utils/search-params.ts`:
- Around line 8-13: The parsing currently allows non-integer positive numbers
and zeros/negatives for legacy scan IDs; update the logic in the scanIds parsing
helper (the function that maps raw -> scanIds in search-params.ts) to only
accept positive integers: trim and split as before, parse each value using
Number or parseInt then reject any value that is not an integer (e.g.,
Number.isInteger), and also reject values <= 0 so decimals and zero/negative IDs
are filtered out; apply the same stricter integer > 0 rule to the legacy scanId
parsing branch mentioned (lines ~20-23) so both code paths use identical
positive-integer validation.
In `@apps/web/src/app/wrong/scans/`[id]/components/scan-bottom-nav.tsx:
- Around line 19-37: Wrap the bottom navigation div (s.bottomNav) in a semantic
nav landmark with an appropriate aria-label (e.g., "Scan navigation") instead of
just a div, and update the component that renders the Icon inside the buttons
(Icon usage on lines with "chevron") to include aria-hidden="true" so screen
readers ignore decoration; ensure the interactive buttons (using prevItem,
nextItem and onMove) retain clear accessible labels (or add explicit
aria-labels) if their visible text isn't sufficient for assistive tech.
In `@apps/web/src/app/wrong/scans/`[id]/components/scan-edit-modal.tsx:
- Around line 138-177: The custom type list only supports drag-and-drop, which
blocks keyboard users; update the custom type item rendering (the mapped block
using customSelectedTypes and element class s.customTypeItem) to add
keyboard-accessible reordering: add visible or visually-hidden "Move up" and
"Move down" buttons (or arrow key handlers) that call onCustomTypeMove with the
current type.id and the target sibling id, ensure the remove button
(onCustomTypeRemove) remains a focusable control, add appropriate aria-labels
and role="listitem" on each item and aria-live or aria-relevant notifications
for order changes, and add onKeyDown handlers on the item to support Enter/Space
to open move controls and ArrowUp/ArrowDown to trigger the same onCustomTypeMove
behavior; keep draggedTypeIdRef logic for mouse drag but ensure keyboard actions
invoke the same onCustomTypeMove function so both input methods update order
consistently.
- Around line 64-69: The modal container currently rendered as <div
className={s.sheet}> lacks ARIA dialog attributes; update the element in
scan-edit-modal.tsx (the element with className s.sheet and the header with
className s.sheetHeader) to include role="dialog" and aria-modal="true", and
wire aria-labelledby to the header by giving the header a stable id (or use
aria-label on the sheet) so screen readers recognize it as a dialog; ensure the
overlay onClick/onClose logic remains unchanged.
In `@apps/web/src/app/wrong/scans/`[id]/page.css.ts:
- Around line 259-269: 현재 sheet 스타일에서 height가 고정된 "54rem"으로 설정되어 있어 작은 뷰포트에서 상단이
잘릴 수 있습니다; 파일의 sheet 스타일 선언을 수정하여 고정 높이를 제거하거나 유지하되 뷰포트 기반 제한을 추가하세요 (예: set
height to min(54rem, 100dvh) or keep height: "54rem" and add maxHeight:
"100dvh") so the bottom sheet never exceeds the viewport; update the export
const sheet style block accordingly to include a maxHeight/height expression
using 100dvh to ensure responsive behavior.
In `@apps/web/src/app/wrong/scans/`[id]/page.tsx:
- Around line 363-370: handleCustomTypeRemove currently both removes the type
from the local selection and calls setProblemTypeActiveMutation to set
active:false, which globally disables the problem type; update
handleCustomTypeRemove to only call setSelectedTypes(prev => prev.filter(name =>
name !== type.name)) and remove the mutateAsync call, and instead move the
global deactivation logic into a separate confirmed flow (e.g.,
handleGlobalDisableProblemType or a "delete/disable" button) that invokes
setProblemTypeActiveMutation.mutateAsync({ typeId: type.id, body: { active:
false }}) after an explicit user confirmation; ensure any optimistic UI updates
are handled in the separate flow rather than in handleCustomTypeRemove.
- Around line 130-134: The initial answerMode is hardcoded to "objective", so
saved subjective items reopen as objective and a submit can overwrite the stored
answer; change the initialization of answerMode to derive from
groupItem?.answerType (map saved types like "TEXT"/"NUMBER"/"EXPRESSION" to the
subjective AnswerMode and "CHOICE"/"CHOICE_MULTIPLE" to objective), e.g.
setAnswerMode(groupItem ? mapAnswerTypeToMode(groupItem.answerType) :
"objective"), and update the submit logic around the payload (the code that
builds the CHOICE payload in the handler used at lines ~195-207) to respect the
current answerMode and existing groupItem values (use groupItem?.answerChoiceNo
as fallback, and only emit CHOICE with a non-null answerChoiceNo; for subjective
modes emit the correct answerType and answerValue) so saved answers are
preserved and not overwritten with nulls.
In `@apps/web/src/shared/apis/problem-create/problem-create-api.ts`:
- Around line 68-79: The normalizeBulkCreateResponse function must not swallow
unexpected server shapes by returning { problems: [] }; instead throw a
descriptive error so callers surface failure and trigger error UI/retry. Replace
the fallback return in normalizeBulkCreateResponse with throwing an Error
(including context like "[problem-create] normalizeBulkCreateResponse:
unexpected response" and the raw data or serialized shape) so the caller's error
handling can run; keep the thrown error type consistent with existing API error
handling path and update any upstream call sites if they assume a successful
empty result.
In
`@apps/web/src/shared/apis/problem-scan/hooks/use-create-problem-scan-group-mutation.ts`:
- Around line 10-18: Remove the redundant per-scan invalidation in the onSuccess
handler: the call to qc.invalidateQueries({ queryKey: problemScanQueryKeys.all
}) already invalidates child keys, so delete the data.scanIds.forEach(...) loop
and keep only the single qc.invalidateQueries({ queryKey:
problemScanQueryKeys.all }) call inside the onSuccess of the
use-create-problem-scan-group-mutation hook; ensure references are to
problemScanQueryKeys and qc.invalidateQueries and that onSuccess still accepts
the data parameter.
In `@apps/web/src/shared/components/app-bar/app-bar.css.ts`:
- Around line 221-234: titleDropdown 드롭다운에 높이 제한과 세로 스크롤이 없어 큰 그룹에서 항목이 잘리는 문제
해결: titleDropdown 스타일 객체에 maxHeight(예: maxHeight: "min(60vh, 20rem)" 또는 프로젝트의
반응형 변수 사용)와 overflowY: "auto"를 추가하고 필요하면 WebKit 터치 스크롤 지원을 위한
"-webkit-overflow-scrolling": "touch" 및 overscrollBehavior: "contain"을 추가해 뷰포트
내에서 스크롤 가능한 메뉴가 되도록 수정하세요.
In `@apps/web/src/shared/components/app-bar/app-bar.tsx`:
- Around line 237-246: The API ambiguity arises because props.rightLabel and
props.onRightClick are both optional so a disabled button can be rendered;
update the prop types for the AppBar component (where props.rightLabel and
props.onRightClick are declared) to enforce a clear contract: either make
onRightClick required whenever rightLabel is present (e.g., use a
discriminated/conditional type or overload so rightLabel implies onRightClick)
or collapse them into a single object prop (e.g., rightAction: { label: string;
onClick: () => void }) and update the rendering logic around
props.rightLabel/props.onRightClick (the JSX that renders the button with class
s.rightLabelButton) to use the new type shape.
In `@apps/web/src/shared/components/app-bar/hooks/use-app-bar.ts`:
- Around line 280-283: The onBack handler currently always pushes to
`${ROUTES.WRONG.CREATE_SCANS}?group=${encodeURIComponent(groupId ?? "")}`, which
yields an invalid URL when groupId is empty; update the onBack logic in
useAppBar (the onBack handler that calls router.push) to check groupId first
and, if missing, fall back to a safe route or behavior (e.g., call router.back()
or navigate to ROUTES.CREATE) instead of building a URL with an empty group
query param so the flow can recover without group context.
---
Outside diff comments:
In `@apps/web/src/shared/components/app-bar/utils/app-bar-routing.ts`:
- Around line 39-46: The route parser currently treats any two-segment path as a
generic detail (if segments.length === 2) which causes "/wrong/scans" to be
misclassified as { type: "detail", id: "scans" }; update the guard in that
branch to exclude reserved segment names (at least "scans") before returning a
detail type. Specifically, in the branch checking segments.length === 2 (and the
same code path that constructs { type: "detail" as const, id: segments[1]! }),
add a conditional that skips returning a detail when segments[1] === "scans" (or
other reserved tokens), falling through so the subsequent scans-specific branch
(segments.length === 3 && segments[1] === "scans") can match correctly. Ensure
tests or callers using the function that depends on segments and the returned
type still behave as expected.
---
Nitpick comments:
In `@apps/web/src/app/wrong/create/components/steps/step-1.tsx`:
- Around line 35-41: The current loop over files calls validateImageFile(file)
and immediately calls window.alert(error) and returns on the first error, which
hides which file(s) failed; instead, collect validation results for all files
(use the existing files variable and validateImageFile) and build a
user-friendly aggregated message that includes each failing file name and its
specific error, then present that consolidated feedback (e.g., via component
state and a visible error list or a single alert with all entries) rather than
returning on the first error; update the handler that currently uses
window.alert to display the aggregated message or render the error list in the
component UI.
In `@apps/web/src/app/wrong/create/hooks/use-wrong-create-route.ts`:
- Around line 22-44: The goStep function is recreated on every render causing
unnecessary downstream updates; wrap goStep with React's useCallback to memoize
it and list its external dependencies (spString, pathname, router, and clamp if
it's not a stable import) in the dependency array so it only changes when those
values change; ensure useCallback is imported and keep the same signature
(nextStep, extra?) and behavior inside the memoized function.
In `@apps/web/src/app/wrong/create/page.tsx`:
- Around line 30-39: The component WrongCreatePage unnecessarily destructures
pathname from useWrongCreateRoute; remove pathname from the destructuring so the
line becomes const { router, params, scanIds, groupId, goStep } =
useWrongCreateRoute(); leaving the rest (copy = WRONG_CREATE_STEP_COPY[1] and
the call to useStep1SummaryTransition) unchanged to keep behavior identical
while cleaning up the unused variable.
In `@apps/web/src/app/wrong/scans/`[id]/components/scan-detail-hero.tsx:
- Around line 35-42: The edit button rendered in the ScanDetailHero component
lacks an explicit aria-label; update the <button> that uses className
s.editButton, onClick onEditClick and contains <Icon name="edit-scan"> to
include a descriptive aria-label (e.g., aria-label="단원 수정하기" or a localized
equivalent) so screen readers announce the control clearly while keeping visible
text unchanged.
In `@apps/web/src/shared/apis/problem-create/problem-create-types.ts`:
- Around line 20-22: Change the mutable `problems: ProblemCreateResponse[]` in
the `ProblemBulkCreateResponse` type to an immutable collection so callers can't
accidentally mutate cached responses; update the field to use a readonly type
such as `readonly ProblemCreateResponse[]` (or
`ReadonlyArray<ProblemCreateResponse>`) in `ProblemBulkCreateResponse` and
adjust any call sites that rely on mutation to avoid in-place changes.
In `@apps/web/src/shared/components/app-bar/app-bar-gate.tsx`:
- Around line 9-15: AppBarGate currently imports the feature-specific
readWrongCreateGroupContext (from "@/app/wrong/create/...") which increases
coupling; instead modify AppBarGate to accept the dependency from its parent:
remove the direct import and have AppBarGate accept a prop (e.g.,
getWrongCreateGroupContext) and pass that into useAppBar; update callers
(pages/layouts) to import readWrongCreateGroupContext and inject it when
rendering AppBarGate so the shared component only depends on the interface used
by useAppBar and not the concrete feature implementation.
In `@apps/web/src/shared/components/app-bar/app-bar.tsx`:
- Around line 207-233: The dropdown rendered when props.titleMenu.isOpen lacks
keyboard and outside-click handling; add an effect and handlers to close on
Escape and close on outside clicks, plus move focus to the first item when
opened. Specifically, in the component that renders the div with className
s.titleDropdown and buttons with s.titleDropdownItem, add a useEffect that
listens for "keydown" (handle Escape to call props.titleMenu.onToggle) and for
clicks outside the s.titleDropdown element (use a useClickOutside hook or a
document click listener that calls props.titleMenu.onToggle), and when
props.titleMenu.isOpen becomes true programmatically set focus to the first item
(e.g., the first button from props.titleMenu.items) so arrow/keyboard navigation
starts there; ensure you clean up listeners on unmount and when isOpen changes
and preserve existing onClick handlers on each item.
In `@apps/web/src/shared/components/chip/chip.tsx`:
- Around line 23-41: Split the existing ChipProps into a discriminated union
based on the as prop so that interactive button props are only allowed when as
is a button: replace the current single ChipProps (which mixes
Pick<ButtonHTMLAttributes, "disabled" | "onClick" | "type">) with two variants
(e.g., ButtonChipProps having as?: "button" | undefined plus
onClick/disabled/type, and SpanChipProps having as: "span" and no
onClick/disabled/type), keep shared fields (label, icon, tone, etc.) in a common
base, and update usages of ChipProps/Chip component to use the new union; ensure
the discriminant is the as property so TypeScript will block passing
onClick/disabled/type when as="span".
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: e305acca-0ae8-45ee-bdac-700c53fd00f0
⛔ Files ignored due to path filters (2)
apps/web/src/shared/assets/icons/edit-scan.svgis excluded by!**/*.svg,!**/src/shared/assets/**apps/web/src/shared/assets/icons/triangle-scan.svgis excluded by!**/*.svg,!**/src/shared/assets/**
📒 Files selected for processing (58)
apps/web/src/app/login/info/components/sections/profile-section/profile-section.tsxapps/web/src/app/my/edit/components/my-profile-section.tsxapps/web/src/app/my/page.tsxapps/web/src/app/wrong/create/components/direct-add-button/direct-add-button.css.tsapps/web/src/app/wrong/create/components/direct-add-button/direct-add-button.tsxapps/web/src/app/wrong/create/components/steps/step-1.tsxapps/web/src/app/wrong/create/components/steps/step-2.tsxapps/web/src/app/wrong/create/components/steps/step-3.tsxapps/web/src/app/wrong/create/components/steps/step-4.tsxapps/web/src/app/wrong/create/components/steps/step.css.tsapps/web/src/app/wrong/create/components/wrong-create-steps/wrong-create-steps.tsxapps/web/src/app/wrong/create/done/page.tsxapps/web/src/app/wrong/create/hooks/step1/use-image-source-picker.tsapps/web/src/app/wrong/create/hooks/step1/use-step1-summary-transition.tsapps/web/src/app/wrong/create/hooks/step2/use-step-2-selection.tsapps/web/src/app/wrong/create/hooks/step3/use-active-type-view.tsapps/web/src/app/wrong/create/hooks/step3/use-async-id-lock.tsapps/web/src/app/wrong/create/hooks/step3/use-custom-type-order.tsapps/web/src/app/wrong/create/hooks/step3/use-optimistic-problem-types.tsapps/web/src/app/wrong/create/hooks/step3/use-recommended-types.tsapps/web/src/app/wrong/create/hooks/step3/use-sanitized-url-type-ids.tsapps/web/src/app/wrong/create/hooks/step3/use-selected-type-ids.tsapps/web/src/app/wrong/create/hooks/step3/use-step-3-selection.tsapps/web/src/app/wrong/create/hooks/step3/use-type-add-flow.tsapps/web/src/app/wrong/create/hooks/step3/use-type-ids-param.tsapps/web/src/app/wrong/create/hooks/step3/use-type-maintenance.tsapps/web/src/app/wrong/create/hooks/step4/use-step4-form.tsapps/web/src/app/wrong/create/hooks/use-pointer-sort-ids.tsapps/web/src/app/wrong/create/hooks/use-wrong-create-route.tsapps/web/src/app/wrong/create/hooks/use-wrong-create-submit.tsapps/web/src/app/wrong/create/page.tsxapps/web/src/app/wrong/create/scans/page.tsxapps/web/src/app/wrong/create/scans/scans.css.tsapps/web/src/app/wrong/create/utils/group-context.tsapps/web/src/app/wrong/create/utils/search-params.tsapps/web/src/app/wrong/scans/[id]/components/scan-answer-section.tsxapps/web/src/app/wrong/scans/[id]/components/scan-bottom-nav.tsxapps/web/src/app/wrong/scans/[id]/components/scan-detail-hero.tsxapps/web/src/app/wrong/scans/[id]/components/scan-edit-modal.tsxapps/web/src/app/wrong/scans/[id]/page.css.tsapps/web/src/app/wrong/scans/[id]/page.tsxapps/web/src/shared/apis/constants/api-paths.tsapps/web/src/shared/apis/problem-create/hooks/use-create-bulk-wrong-answer-cards-mutation.tsapps/web/src/shared/apis/problem-create/problem-create-api.tsapps/web/src/shared/apis/problem-create/problem-create-types.tsapps/web/src/shared/apis/problem-scan/hooks/use-create-problem-scan-group-mutation.tsapps/web/src/shared/apis/problem-scan/problem-scan-api.tsapps/web/src/shared/apis/problem-scan/problem-scan-types.tsapps/web/src/shared/components/app-bar/app-bar-gate.tsxapps/web/src/shared/components/app-bar/app-bar.css.tsapps/web/src/shared/components/app-bar/app-bar.tsxapps/web/src/shared/components/app-bar/hooks/use-app-bar.tsapps/web/src/shared/components/app-bar/types/app-bar.tsapps/web/src/shared/components/app-bar/utils/app-bar-routing.tsapps/web/src/shared/components/chip/chip.tsxapps/web/src/shared/constants/icons.tsapps/web/src/shared/constants/routes.tsapps/web/src/shared/constants/sprite.ts
💤 Files with no reviewable changes (20)
- apps/web/src/app/wrong/create/components/steps/step-3.tsx
- apps/web/src/app/wrong/create/hooks/step3/use-type-add-flow.ts
- apps/web/src/app/wrong/create/hooks/step3/use-recommended-types.ts
- apps/web/src/app/wrong/create/hooks/step3/use-optimistic-problem-types.ts
- apps/web/src/app/wrong/create/hooks/step3/use-async-id-lock.ts
- apps/web/src/app/wrong/create/components/steps/step-2.tsx
- apps/web/src/app/wrong/create/hooks/step4/use-step4-form.ts
- apps/web/src/app/wrong/create/hooks/step3/use-sanitized-url-type-ids.ts
- apps/web/src/app/wrong/create/hooks/use-pointer-sort-ids.ts
- apps/web/src/app/wrong/create/hooks/step3/use-active-type-view.ts
- apps/web/src/app/wrong/create/components/wrong-create-steps/wrong-create-steps.tsx
- apps/web/src/app/wrong/create/hooks/step3/use-type-maintenance.ts
- apps/web/src/app/wrong/create/hooks/use-wrong-create-submit.ts
- apps/web/src/app/wrong/create/components/steps/step-4.tsx
- apps/web/src/app/wrong/create/hooks/step3/use-type-ids-param.ts
- apps/web/src/app/wrong/create/hooks/step3/use-custom-type-order.ts
- apps/web/src/app/wrong/create/hooks/step3/use-selected-type-ids.ts
- apps/web/src/app/wrong/create/hooks/step3/use-step-3-selection.ts
- apps/web/src/app/wrong/create/hooks/step2/use-step-2-selection.ts
- apps/web/src/app/wrong/create/components/steps/step.css.ts
| export const titleDropdown = style([ | ||
| bgColor["grayscale-0"], | ||
| { | ||
| position: "absolute", | ||
| top: "calc(100% + 1.2rem)", | ||
| left: "50%", | ||
| transform: "translateX(-50%)", | ||
| width: "14rem", | ||
| borderRadius: vars.radius.r16, | ||
| boxShadow: vars.shadow.e400, | ||
| overflow: "hidden", | ||
| zIndex: vars.zIndex.contentOverlayHigh, | ||
| }, | ||
| ]); |
There was a problem hiding this comment.
드롭다운에 높이 제한이 없어 큰 그룹에서 항목이 잘립니다.
다중 등록에서는 문제 수가 많아질 수 있는데, 현재 드롭다운은 maxHeight/overflowY가 없어서 하단 항목이 뷰포트 밖으로 밀리면 선택할 수 없습니다. 그룹 내 빠른 이동이 핵심 UX라서 여기서는 스크롤 가능한 메뉴가 필요합니다.
💡 제안 수정안
export const titleDropdown = style([
bgColor["grayscale-0"],
{
position: "absolute",
top: "calc(100% + 1.2rem)",
left: "50%",
transform: "translateX(-50%)",
width: "14rem",
+ maxHeight: "min(32rem, calc(100vh - 8rem))",
borderRadius: vars.radius.r16,
boxShadow: vars.shadow.e400,
overflow: "hidden",
+ overflowY: "auto",
+ overscrollBehavior: "contain",
zIndex: vars.zIndex.contentOverlayHigh,
},
]);As per coding guidelines "반응형 스타일 구현 방식".
📝 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.
| export const titleDropdown = style([ | |
| bgColor["grayscale-0"], | |
| { | |
| position: "absolute", | |
| top: "calc(100% + 1.2rem)", | |
| left: "50%", | |
| transform: "translateX(-50%)", | |
| width: "14rem", | |
| borderRadius: vars.radius.r16, | |
| boxShadow: vars.shadow.e400, | |
| overflow: "hidden", | |
| zIndex: vars.zIndex.contentOverlayHigh, | |
| }, | |
| ]); | |
| export const titleDropdown = style([ | |
| bgColor["grayscale-0"], | |
| { | |
| position: "absolute", | |
| top: "calc(100% + 1.2rem)", | |
| left: "50%", | |
| transform: "translateX(-50%)", | |
| width: "14rem", | |
| maxHeight: "min(32rem, calc(100vh - 8rem))", | |
| borderRadius: vars.radius.r16, | |
| boxShadow: vars.shadow.e400, | |
| overflow: "hidden", | |
| overflowY: "auto", | |
| overscrollBehavior: "contain", | |
| zIndex: vars.zIndex.contentOverlayHigh, | |
| }, | |
| ]); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/shared/components/app-bar/app-bar.css.ts` around lines 221 -
234, titleDropdown 드롭다운에 높이 제한과 세로 스크롤이 없어 큰 그룹에서 항목이 잘리는 문제 해결: titleDropdown
스타일 객체에 maxHeight(예: maxHeight: "min(60vh, 20rem)" 또는 프로젝트의 반응형 변수 사용)와
overflowY: "auto"를 추가하고 필요하면 WebKit 터치 스크롤 지원을 위한 "-webkit-overflow-scrolling":
"touch" 및 overscrollBehavior: "contain"을 추가해 뷰포트 내에서 스크롤 가능한 메뉴가 되도록 수정하세요.
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/app/wrong/create/hooks/step1/use-step1-summary-transition.ts (1)
244-255:⚠️ Potential issue | 🟠 Major
groupId없는 업로드 응답은 성공으로 처리하면 안 됩니다.여기서
scanIds만 저장되면isStep1Blocked는true가 되는데, 실제 폴링은!groupId에서 바로 종료됩니다. 그 결과 화면이 영구히 막힐 수 있으니res.groupId가 없으면 즉시 실패 처리하고 복구 경로를 열어 주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/app/wrong/create/hooks/step1/use-step1-summary-transition.ts` around lines 244 - 255, The handler treats responses without res.groupId as success which leaves scanIds saved but polling exits on !groupId and the UI gets stuck; update the callback in use-step1-summary-transition (the arrow handler that currently sets createdFlowRef.current and calls goStep) to explicitly treat a missing res.groupId as a failure: when res.groupId is falsy, clear createdFlowRef.current, invoke the existing failure/recovery flow (e.g., set the step error state or call the step-failure handler used elsewhere), and do NOT call goStep(1, {...}) with a null groupId; only call goStep when res.groupId is present to guarantee polling and isStep1Blocked remain consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/src/app/wrong/create/hooks/step1/use-step1-summary-transition.ts`:
- Around line 204-208: The catch block after pollSummariesRef.current currently
retries indefinitely by clearing and scheduling timerRef with POLL_INTERVAL_MS;
change it to track retry attempts or elapsed time (e.g., a retryCount or
startTime stored alongside timerRef/pollSummariesRef), stop retrying after a
maxRetry or timeout threshold, call resetFlow() and show a user-facing
toast/error notification when threshold is exceeded, and ensure authentication
errors (401/403) are treated as terminal by short-circuiting to resetFlow()
immediately rather than scheduling another poll; update the catch in
use-step1-summary-transition to consult these counters/response status and
perform the terminal path.
In `@apps/web/src/app/wrong/scans/`[id]/hooks/use-scan-answer-actions.ts:
- Around line 20-22: The hook keeps stale answer state because
useState(initial*) only uses the initial values once; update
use-scan-answer-actions.ts (in the hook using answerMode/answerChoice/answerText
and initialMode/initialChoice/initialText) to reset those states whenever the
initial props change by adding a useEffect that calls
setAnswerMode(initialMode), setAnswerChoice(initialChoice) and
setAnswerText(initialText) with [initialMode, initialChoice, initialText] as the
dependency array so the hook syncs when the scan changes.
In `@apps/web/src/app/wrong/scans/`[id]/hooks/use-wrong-scan-detail.ts:
- Around line 96-103: The current mapping in use-wrong-scan-detail.ts uses
resolvedTypeIds.length > 0 ? resolvedTypeIds : groupItem.finalTypeIds which
preserves previous groupItem.finalTypeIds when the user clears all types; change
it to always assign finalTypeIds: resolvedTypeIds (so an empty selection becomes
an empty array) and remove the fallback to groupItem.finalTypeIds; apply the
same change to the analogous block referenced around the other instance (lines
~172-180) so both places persist an explicit empty array when the user clears
all types.
In `@apps/web/src/app/wrong/scans/`[id]/payload.ts:
- Around line 32-58: resolveFinalTypeIds can trigger duplicate createType calls
for the same new type across a batch; fix by introducing a shared promise cache
keyed by the normalized type name so concurrent requests reuse one creation
promise/result. Update resolveFinalTypeIds (and the other similar block at
123-145) to: normalize+trim the typeName into a key, check a shared Map<string,
Promise<ProblemTypeItem> | string> (or two maps) for an existing promise/id
before calling createType, store the promise in the map immediately when
creating so subsequent callers await the same promise, and return the resolved
id; ensure you still consult existing problemTypes first and use normalized keys
consistently.
- Around line 123-149: The code only validates and resolves
finalUnitId/finalTypeIds for nextCurrentItem and serializes other items via
buildStoredProblemPayload, which can let incomplete review-needed items with
empty unit/type slip into the bulk payload; update the payload construction so
you first iterate over all items (the items array) and for each item (compare
using scanId with nextCurrentItem) compute/resolve finalUnitId via
resolveItemFinalUnitId and finalTypeIds via resolveFinalTypeIds (using the same
fallback and problemTypes/createType inputs) before serializing, use
buildProblemPayload for the current item and buildStoredProblemPayload for
others but only after resolution, and if any item fails resolution or yields
missing/invalid unit/type, prevent the bulk create (return an error or throw)
instead of proceeding.
---
Outside diff comments:
In `@apps/web/src/app/wrong/create/hooks/step1/use-step1-summary-transition.ts`:
- Around line 244-255: The handler treats responses without res.groupId as
success which leaves scanIds saved but polling exits on !groupId and the UI gets
stuck; update the callback in use-step1-summary-transition (the arrow handler
that currently sets createdFlowRef.current and calls goStep) to explicitly treat
a missing res.groupId as a failure: when res.groupId is falsy, clear
createdFlowRef.current, invoke the existing failure/recovery flow (e.g., set the
step error state or call the step-failure handler used elsewhere), and do NOT
call goStep(1, {...}) with a null groupId; only call goStep when res.groupId is
present to guarantee polling and isStep1Blocked remain consistent.
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 6bf03b84-3418-4940-9b80-47ab115ad078
📒 Files selected for processing (18)
apps/web/src/app/wrong/create/components/steps/step-1.tsxapps/web/src/app/wrong/create/hooks/step1/use-step1-summary-transition.tsapps/web/src/app/wrong/create/scans/page.tsxapps/web/src/app/wrong/create/utils/group-context.tsapps/web/src/app/wrong/create/utils/search-params.tsapps/web/src/app/wrong/scans/[id]/components/scan-edit-modal.tsxapps/web/src/app/wrong/scans/[id]/hooks/use-scan-answer-actions.tsapps/web/src/app/wrong/scans/[id]/hooks/use-wrong-scan-detail.tsapps/web/src/app/wrong/scans/[id]/page.css.tsapps/web/src/app/wrong/scans/[id]/page.tsxapps/web/src/app/wrong/scans/[id]/payload.tsapps/web/src/app/wrong/scans/[id]/type-actions.tsapps/web/src/app/wrong/scans/[id]/utils.tsapps/web/src/shared/apis/problem-create/problem-create-api.tsapps/web/src/shared/apis/problem-scan/hooks/use-create-problem-scan-group-mutation.tsapps/web/src/shared/components/app-bar/app-bar.tsxapps/web/src/shared/components/app-bar/hooks/use-app-bar.tsapps/web/src/shared/components/app-bar/types/app-bar.ts
🚧 Files skipped from review as they are similar to previous changes (11)
- apps/web/src/shared/apis/problem-scan/hooks/use-create-problem-scan-group-mutation.ts
- apps/web/src/app/wrong/create/utils/search-params.ts
- apps/web/src/shared/apis/problem-create/problem-create-api.ts
- apps/web/src/app/wrong/scans/[id]/page.tsx
- apps/web/src/app/wrong/create/scans/page.tsx
- apps/web/src/shared/components/app-bar/types/app-bar.ts
- apps/web/src/shared/components/app-bar/app-bar.tsx
- apps/web/src/app/wrong/create/utils/group-context.ts
- apps/web/src/app/wrong/create/components/steps/step-1.tsx
- apps/web/src/app/wrong/scans/[id]/components/scan-edit-modal.tsx
- apps/web/src/app/wrong/scans/[id]/page.css.ts
| } catch { | ||
| clearTimer(); | ||
| timerRef.current = setTimeout(() => { | ||
| pollSummariesRef.current(); | ||
| }, POLL_INTERVAL_MS); |
There was a problem hiding this comment.
네트워크 오류 시 여기서 무기한 폴링에 갇힙니다.
이 catch는 모든 예외를 같은 방식으로 재시도만 해서, 인증 오류나 지속적인 5xx에서도 Step 1이 끝나지 않습니다. 사용자는 계속 막힌 상태만 보게 되니 최대 재시도/타임아웃 후 토스트를 띄우고 resetFlow()로 빠져나오는 경로가 필요합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/app/wrong/create/hooks/step1/use-step1-summary-transition.ts`
around lines 204 - 208, The catch block after pollSummariesRef.current currently
retries indefinitely by clearing and scheduling timerRef with POLL_INTERVAL_MS;
change it to track retry attempts or elapsed time (e.g., a retryCount or
startTime stored alongside timerRef/pollSummariesRef), stop retrying after a
maxRetry or timeout threshold, call resetFlow() and show a user-facing
toast/error notification when threshold is exceeded, and ensure authentication
errors (401/403) are treated as terminal by short-circuiting to resetFlow()
immediately rather than scheduling another poll; update the catch in
use-step1-summary-transition to consult these counters/response status and
perform the terminal path.
| const [answerMode, setAnswerMode] = useState<AnswerMode>(initialMode); | ||
| const [answerChoice, setAnswerChoice] = useState<number | null>(initialChoice); | ||
| const [answerText, setAnswerText] = useState(initialText); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd 'page.tsx' apps/web/src/app/wrong/scans -x sed -n '1,220p' {}
echo '---'
rg -n -C3 'useWrongScanDetail|key=' apps/web/src/app/wrong/scansRepository: Central-MakeUs/Delta-front
Length of output: 8947
🏁 Script executed:
cat -n apps/web/src/app/wrong/scans/[id]/hooks/use-scan-answer-actions.tsRepository: Central-MakeUs/Delta-front
Length of output: 2435
🏁 Script executed:
cat -n apps/web/src/app/wrong/scans/[id]/hooks/use-wrong-scan-detail.ts | head -100Repository: Central-MakeUs/Delta-front
Length of output: 5195
🏁 Script executed:
cat -n apps/web/src/app/wrong/scans/[id]/hooks/use-wrong-scan-detail.ts | sed -n '100,200p'Repository: Central-MakeUs/Delta-front
Length of output: 3878
스캔이 바뀌어도 답안 state가 초기화되지 않습니다.
useState(initial*)는 첫 렌더에서만 적용되므로, moveTo(nextScanId)로 다른 스캔으로 이동할 때 이전 문제의 answerMode/answerChoice/answerText가 남습니다. 같은 hook 인스턴스에서 스캔만 바뀌므로 state가 동기화되지 않고, 이전 답안이 새로운 문제에 표시될 수 있습니다.
initialMode, initialChoice, initialText가 변경될 때 state를 초기화하는 useEffect를 추가해 주세요:
제안된 수정 방안
useEffect(() => {
setAnswerMode(initialMode);
setAnswerChoice(initialChoice);
setAnswerText(initialText);
}, [initialMode, initialChoice, initialText]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/app/wrong/scans/`[id]/hooks/use-scan-answer-actions.ts around
lines 20 - 22, The hook keeps stale answer state because useState(initial*) only
uses the initial values once; update use-scan-answer-actions.ts (in the hook
using answerMode/answerChoice/answerText and
initialMode/initialChoice/initialText) to reset those states whenever the
initial props change by adding a useEffect that calls
setAnswerMode(initialMode), setAnswerChoice(initialChoice) and
setAnswerText(initialText) with [initialMode, initialChoice, initialText] as the
dependency array so the hook syncs when the scan changes.
| const resolvedTypeIds = resolveKnownTypeIds(appliedTypes, problemTypes); | ||
| return { | ||
| ...groupItem, | ||
| finalUnitId: | ||
| resolveUnitId(appliedSubject, appliedUnit) ?? groupItem.finalUnitId, | ||
| finalTypeIds: | ||
| resolvedTypeIds.length > 0 ? resolvedTypeIds : groupItem.finalTypeIds, | ||
| subjectName: appliedSubject, |
There was a problem hiding this comment.
유형을 전부 해제해도 이전 finalTypeIds가 남습니다.
지금 로직은 선택된 유형이 0개이면 []를 유지하지 않고 기존 groupItem.finalTypeIds로 되돌립니다. 그래서 사용자가 유형을 모두 지워도 실제 저장/전송 값은 예전 유형 그대로 남고, review-needed 상태로 되돌릴 수도 없습니다. 빈 선택은 그대로 빈 배열로 저장하고, 완료 단계에서 검증하는 쪽이 맞습니다.
Also applies to: 172-180
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/app/wrong/scans/`[id]/hooks/use-wrong-scan-detail.ts around
lines 96 - 103, The current mapping in use-wrong-scan-detail.ts uses
resolvedTypeIds.length > 0 ? resolvedTypeIds : groupItem.finalTypeIds which
preserves previous groupItem.finalTypeIds when the user clears all types; change
it to always assign finalTypeIds: resolvedTypeIds (so an empty selection becomes
an empty array) and remove the fallback to groupItem.finalTypeIds; apply the
same change to the analogous block referenced around the other instance (lines
~172-180) so both places persist an explicit empty array when the user clears
all types.
| export const resolveFinalTypeIds = async ({ | ||
| typeNames, | ||
| fallbackTypeIds, | ||
| problemTypes, | ||
| createType, | ||
| }: { | ||
| typeNames: string[]; | ||
| fallbackTypeIds: string[]; | ||
| problemTypes: ProblemTypeItem[]; | ||
| createType: (name: string) => Promise<ProblemTypeItem>; | ||
| }) => { | ||
| if (typeNames.length === 0) return fallbackTypeIds; | ||
|
|
||
| return dedupe( | ||
| await Promise.all( | ||
| typeNames.map(async (typeName) => { | ||
| const existing = problemTypes.find( | ||
| (type) => normalize(type.name) === normalize(typeName) | ||
| ); | ||
|
|
||
| if (existing?.id) return existing.id; | ||
|
|
||
| const created = await createType(typeName.trim()); | ||
| return created.id; | ||
| }) | ||
| ) | ||
| ); |
There was a problem hiding this comment.
같은 새 커스텀 유형이 배치 안에서 중복 생성될 수 있습니다.
resolveFinalTypeIds()가 item마다 독립적으로 problemTypes만 보고 createType()를 호출해서, 여러 스캔이 같은 새 유형명을 갖고 있으면 같은 이름으로 생성 요청이 여러 번 나갈 수 있습니다. 이름 기준의 promise cache/shared map을 두고 한 번 만든 결과를 같은 배치 전체에서 재사용하는 쪽이 안전합니다.
Also applies to: 123-145
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/app/wrong/scans/`[id]/payload.ts around lines 32 - 58,
resolveFinalTypeIds can trigger duplicate createType calls for the same new type
across a batch; fix by introducing a shared promise cache keyed by the
normalized type name so concurrent requests reuse one creation promise/result.
Update resolveFinalTypeIds (and the other similar block at 123-145) to:
normalize+trim the typeName into a key, check a shared Map<string,
Promise<ProblemTypeItem> | string> (or two maps) for an existing promise/id
before calling createType, store the promise in the map immediately when
creating so subsequent callers await the same promise, and return the resolved
id; ensure you still consult existing problemTypes first and use normalized keys
consistently.
| const payloadItems = await Promise.all( | ||
| items.map(async (item) => { | ||
| const effectiveItem = | ||
| item.scanId === nextCurrentItem.scanId ? nextCurrentItem : item; | ||
|
|
||
| if (effectiveItem.scanId === nextCurrentItem.scanId) { | ||
| return buildProblemPayload( | ||
| nextCurrentItem, | ||
| currentFinalUnitId, | ||
| currentFinalTypeIds, | ||
| answerMode, | ||
| answerChoice, | ||
| answerText | ||
| ); | ||
| } | ||
|
|
||
| const finalUnitId = resolveItemFinalUnitId(effectiveItem); | ||
| const finalTypeIds = await resolveFinalTypeIds({ | ||
| typeNames: effectiveItem.typeNames, | ||
| fallbackTypeIds: effectiveItem.finalTypeIds, | ||
| problemTypes, | ||
| createType, | ||
| }); | ||
|
|
||
| return buildStoredProblemPayload(effectiveItem, finalUnitId, finalTypeIds); | ||
| }) | ||
| ); |
There was a problem hiding this comment.
현재 문제만 검증하고 나머지 스캔은 그대로 전송됩니다.
여기서는 currentItem만 finalUnitId/finalTypeIds를 확인하고, 나머지 항목은 buildStoredProblemPayload()로 바로 직렬화합니다. Step 1에서 review-needed 항목이 빈 단원/유형 값으로 저장될 수 있어서, 한 문제만 수정한 뒤 완료해도 invalid bulk payload가 섞여 나갈 수 있습니다. 완료 전에 items 전체를 검증하거나, 미완료 항목이 있으면 bulk create를 막아야 합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/src/app/wrong/scans/`[id]/payload.ts around lines 123 - 149, The
code only validates and resolves finalUnitId/finalTypeIds for nextCurrentItem
and serializes other items via buildStoredProblemPayload, which can let
incomplete review-needed items with empty unit/type slip into the bulk payload;
update the payload construction so you first iterate over all items (the items
array) and for each item (compare using scanId with nextCurrentItem)
compute/resolve finalUnitId via resolveItemFinalUnitId and finalTypeIds via
resolveFinalTypeIds (using the same fallback and problemTypes/createType inputs)
before serializing, use buildProblemPayload for the current item and
buildStoredProblemPayload for others but only after resolution, and if any item
fails resolution or yields missing/invalid unit/type, prevent the bulk create
(return an error or throw) instead of proceeding.
🔗 관련 이슈
Closes #168
💡 작업 내용
이번 작업에서는 기존 오답 등록 플로우를
단건, 다단계 등록중심 구조에서스캔 그룹 기반 다중 등록플로우로 개편했어요. 변경사항이 너무 많아서 리뷰하는 데 시간이 꽤 걸릴 것 같아 미안한 마음..1. 오답 생성 플로우 단순화
기존
wrong/create의 4단계(step-1~4) 플로우를 걷어내고, 업로드 후 AI 분류 결과를 기반으로 바로 스캔 목록과 스캔 상세 편집으로 이어지는 흐름으로 변경했어요.step-2,step-3,step-4및 관련 hook들을 삭제했습니다.wrong/create/page.tsx는 업로드 + 그룹 요약 대기 역할만 담당하도록 단순화했습니다./wrong/create/scans로 진입하도록 변경했습니다.2. 스캔 그룹 기반 화면 추가
업로드된 여러 문제를 한 번에 관리할 수 있도록 새로운 화면들을 추가했어요.
/wrong/create/scans전체 / 분류 완료 / 검토 필요탭으로 스캔 상태를 나눠 볼 수 있도록 했습니다.검토 필요(needsReview)여부를 그룹 요약 응답 기준으로 표시할 수 있도록 구성했습니다./wrong/scans/[id]3. 다중 등록 API 흐름 추가
백엔드의 그룹 생성/그룹 요약/벌크 생성 흐름을 프론트에서 처리할 수 있도록 API 레이어를 확장했습니다.
problemScanApi.createGroupproblemScanApi.getGroupSummarycreateBulkWrongAnswerCardsuseCreateProblemScanGroupMutationuseCreateBulkWrongAnswerCardsMutation또 스캔 그룹 생성 응답과 그룹 요약 응답의 shape를 정규화할 수 있도록
problem-scan-api.ts,problem-scan-types.ts를 보강했어요.4. 그룹 컨텍스트를 세션 스토리지에 저장
다중 등록 과정에서 각 스캔의 AI 분류 결과와 사용자가 수정한 값을 유지할 수 있도록 세션 스토리지 기반 그룹 컨텍스트를 추가했습니다.
group-context.ts추가scanId,finalUnitId,finalTypeIds,answerFormat,imageUrl,subjectName,unitName,typeNames,needsReview등을 저장
이 구조로 바꾸면서, 기존 step 기반의 복잡한 URL 파라미터 전달과 선택 상태 관리 코드를 비교적 많이 제거할 수 있었습니다.
5. App Bar를 스캔 상세 흐름에 맞게 확장
스캔 상세 페이지 진입에 맞춰 app bar 라우팅과 UI를 확장했어요.
scanDetailvariant를 추가했습니다.문제 (n/total)형태 타이틀을 표시합니다./wrong/create/scans,/wrong/scans/[id]라우팅을 app bar에서 인식하도록 보강했습니다.드롭다운에 사용한
triangle-scan.svg도 함께 추가했어요. 기존 triangle 아이콘을 재사용하려고 했는데 radius가 조금 달라서,스타일로 억지로 맞추기보다 전용 svg를 추가하는 쪽으로 정리했습니다. 직접 구현하려면 결국
clip-path등으로 형태를 다시 맞춰야 해서 svg 분리가 더 명확하다고 판단했습니다.6. 기타 정리
💬 원하는 리뷰 방식(선택)
변경 파일도 많고 삭제, 추가한 파일이 너무 많아서, 이번에는 CodeRabbit CLI로 전체 리뷰를 한 번 먼저 돌리고 반영해둔 상태입니다. 매도 미리 맞는 게 낫다고 생각해서 ㅎㅎ 가능한 기본 피드백은 먼저 정리해뒀습니다.
그래서 이번 리뷰에서는 다음 사항들을 특히 봐주시면 좋겠어요!
개인적으로는 헤더가 점점 복잡해지고 있어서, 장기적으로 app bar 구조를 어떻게 분리하면 좋을지 고민해보면 좋을 것 같아요. 지금은 라우트별 분기와 variant가 계속 늘어나는 구조라, 추가 기능이 붙을수록 유지보수 난이도가 올라갈 수 있겠다고 느껴서,,
📸 Screenshots or Video(선택)
내일 아침에 추가할게유
Summary by CodeRabbit
Summary by CodeRabbit
주요 변경사항
New Features
Refactor