ITN Dev 07-24
feat: 권리확인 조사원 영역 첨부·기존공공누리 편집 지원, 메모 담당자 최상단 시스템 고정
@8adfcdbb2a1e3932a337ded2fb839175b1d0ad8f
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -354,6 +354,10 @@
 
 /** 권리확인 상세의 [수정] 저장 요청. 전부 선택값이다. */
 export interface JudgmentRequest {
+  hasAttachment: string | null
+  koglAttached: string | null
+  koglType: string | null
+  aiType: string | null
   openable: string | null
   reviewMajor: string | null
   reviewMinor: string | null
frontend/src/components/ReviewBoard.test.tsx
--- frontend/src/components/ReviewBoard.test.tsx
+++ frontend/src/components/ReviewBoard.test.tsx
@@ -246,6 +246,10 @@
 
     await waitFor(() =>
       expect(mocks.updateReviewJudgment).toHaveBeenCalledWith(1, 1, {
+        hasAttachment: 'N',
+        koglAttached: 'Y',
+        koglType: '1유형',
+        aiType: null,
         openable: 'Y',
         reviewMajor: '만료 저작물',
         reviewMinor: null,
frontend/src/components/ReviewBoard.tsx
--- frontend/src/components/ReviewBoard.tsx
+++ frontend/src/components/ReviewBoard.tsx
@@ -506,6 +506,10 @@
   const [loading, setLoading] = useState(true)
   const [loadError, setLoadError] = useState<string | null>(null)
 
+  const [hasAttachment, setHasAttachment] = useState('')
+  const [koglAttached, setKoglAttached] = useState('')
+  const [koglType, setKoglType] = useState('')
+  const [aiType, setAiType] = useState(false)
   const [reviewMajor, setReviewMajor] = useState('')
   const [reviewMinor, setReviewMinor] = useState('')
   const [reviewResult, setReviewResult] = useState('')
@@ -530,6 +534,10 @@
           return
         }
         setItem(data)
+        setHasAttachment(data.hasAttachment ?? '')
+        setKoglAttached(data.koglAttached ?? '')
+        setKoglType(data.koglType ?? '')
+        setAiType(data.aiType === 'Y')
         setReviewMajor(data.reviewMajor ?? '')
         setReviewMinor(data.reviewMinor ?? '')
         setReviewResult(data.reviewResult ?? '')
@@ -588,6 +596,10 @@
     setSaveError(null)
     try {
       const body: JudgmentRequest = {
+        hasAttachment: hasAttachment || null,
+        koglAttached: koglAttached || null,
+        koglType: koglType || null,
+        aiType: aiType ? 'Y' : null,
         // 개방가능여부는 별도 입력칸 없이 처리결과에서 파생한다("신유형 개방"일 때만 개방 가능).
         openable: reviewResult === '신유형 개방' ? 'Y' : reviewResult ? 'N' : null,
         reviewMajor: reviewMajor || null,
@@ -614,8 +626,6 @@
   if (loadError || !item) {
     return <p className="p-4 py-10 text-center text-sm text-red-600">{loadError ?? '게시물을 찾을 수 없습니다.'}</p>
   }
-
-  const koglSummary = `${item.koglAttached ?? '-'} / ${item.koglType ?? '-'}${item.aiType ? ' · AI' : ''}`
 
   return (
     <div className="p-4">
@@ -659,10 +669,62 @@
             right={<ReadonlyBox value={item.publishedDate} />}
           />
           <InfoRow label="첨부파일 여부">
-            <ReadonlyBox value={item.hasAttachment} />
+            <div className="flex items-center gap-5 text-sm">
+              {['있음', '없음'].map((option) => (
+                <label key={option} className="flex cursor-pointer items-center gap-1.5">
+                  <input
+                    type="radio"
+                    name="has-attachment"
+                    aria-label={`첨부파일 ${option}`}
+                    checked={hasAttachment === option}
+                    onChange={() => setHasAttachment(option)}
+                    className="h-3.5 w-3.5 accent-blue-600"
+                  />
+                  {option}
+                </label>
+              ))}
+            </div>
           </InfoRow>
           <InfoRow label="기존 공공누리">
-            <ReadonlyBox value={koglSummary} />
+            <div className="flex flex-wrap items-center gap-5 text-sm">
+              {['부착', '미부착'].map((option) => (
+                <label key={option} className="flex cursor-pointer items-center gap-1.5">
+                  <input
+                    type="radio"
+                    name="kogl-attached"
+                    aria-label={`기존 공공누리 ${option}`}
+                    checked={koglAttached === option}
+                    onChange={() => setKoglAttached(option)}
+                    className="h-3.5 w-3.5 accent-blue-600"
+                  />
+                  {option}
+                </label>
+              ))}
+              <label className="flex items-center gap-1.5">
+                유형
+                <select
+                  aria-label="기존 공공누리 유형"
+                  value={koglType}
+                  onChange={(e) => setKoglType(e.target.value)}
+                  className="rounded border border-gray-300 px-2 py-1 text-sm"
+                >
+                  <option value="">선택</option>
+                  {['0유형', '1유형', '2유형', '3유형', '4유형'].map((t) => (
+                    <option key={t} value={t}>{t}</option>
+                  ))}
+                </select>
+              </label>
+              <label className="flex cursor-pointer items-center gap-1.5">
+                <input
+                  type="checkbox"
+                  aria-label="기존 공공누리 AI유형"
+                  checked={aiType}
+                  onChange={(e) => setAiType(e.target.checked)}
+                  className="h-3.5 w-3.5 accent-blue-600"
+                />
+                AI유형
+              </label>
+            </div>
           </InfoRow>
         </div>
       </section>
frontend/src/components/WorkMemos.test.tsx
--- frontend/src/components/WorkMemos.test.tsx
+++ frontend/src/components/WorkMemos.test.tsx
@@ -158,6 +158,8 @@
 
     render(<WorkMemos org={org()} />)
 
+    fireEvent.change(screen.getByLabelText('담당자'), { target: { value: '송민지 과장' } })
+
     await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled())
 
     fireEvent.change(screen.getByLabelText('메모 내용'), { target: { value: '통화 내용' } })
frontend/src/components/WorkMemos.tsx
--- frontend/src/components/WorkMemos.tsx
+++ frontend/src/components/WorkMemos.tsx
@@ -32,7 +32,8 @@
     org.lawyer?.name ?? null,
   ]
   const seen = new Set<string>()
-  const options: string[] = []
+  // 요구사항: 선택박스 최상단은 항상 '시스템'이다 (자동/시스템성 메모 구분용)
+  const options: string[] = ['시스템']
   for (const candidate of candidates) {
     if (!candidate || candidate.trim() === '' || seen.has(candidate)) {
       continue
src/main/java/kr/itn/itnhub/review/JudgmentRequest.java
--- src/main/java/kr/itn/itnhub/review/JudgmentRequest.java
+++ src/main/java/kr/itn/itnhub/review/JudgmentRequest.java
@@ -2,6 +2,10 @@
 
 /** 권리확인 상세의 [수정] 저장 요청. 전부 선택값이며, 저장 시 web_edited_at이 채워진다. */
 public record JudgmentRequest(
+        String hasAttachment,
+        String koglAttached,
+        String koglType,
+        String aiType,
         String openable,
         String reviewMajor,
         String reviewMinor,
src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
--- src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
+++ src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
@@ -32,6 +32,10 @@
     int upsertFromFile(ReviewItem item);
 
     int updateJudgment(@Param("orgId") Long orgId, @Param("id") Long id,
+                       @Param("hasAttachment") String hasAttachment,
+                       @Param("koglAttached") String koglAttached,
+                       @Param("koglType") String koglType,
+                       @Param("aiType") String aiType,
                         @Param("openable") String openable,
                         @Param("reviewMajor") String reviewMajor,
                         @Param("reviewMinor") String reviewMinor,
src/main/java/kr/itn/itnhub/review/ReviewService.java
--- src/main/java/kr/itn/itnhub/review/ReviewService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewService.java
@@ -56,6 +56,7 @@
     public ReviewItem updateJudgment(Long orgId, Long itemId, JudgmentRequest request) {
         requireOrg(orgId);
         int updated = mapper.updateJudgment(orgId, itemId,
+                request.hasAttachment(), request.koglAttached(), request.koglType(), request.aiType(),
                 request.openable(), request.reviewMajor(), request.reviewMinor(), request.reviewResult(),
                 request.judgedKoglType(), request.judgedAiType(), request.opinion(), request.lawyerNote(),
                 request.needsProcessing());
src/main/resources/mapper/ReviewItemMapper.xml
--- src/main/resources/mapper/ReviewItemMapper.xml
+++ src/main/resources/mapper/ReviewItemMapper.xml
@@ -94,10 +94,10 @@
       post_registered = excluded.post_registered,
       produced_date   = excluded.produced_date,
       published_date  = excluded.published_date,
-      has_attachment  = excluded.has_attachment,
-      kogl_attached   = excluded.kogl_attached,
-      kogl_type       = excluded.kogl_type,
-      ai_type         = excluded.ai_type,
+      has_attachment  = case when review_item.web_edited_at is null then excluded.has_attachment else review_item.has_attachment end,
+      kogl_attached   = case when review_item.web_edited_at is null then excluded.kogl_attached else review_item.kogl_attached end,
+      kogl_type       = case when review_item.web_edited_at is null then excluded.kogl_type else review_item.kogl_type end,
+      ai_type         = case when review_item.web_edited_at is null then excluded.ai_type else review_item.ai_type end,
       surveyor_note   = excluded.surveyor_note,
       openable        = case when review_item.web_edited_at is null then excluded.openable else review_item.openable end,
       review_major    = case when review_item.web_edited_at is null then excluded.review_major else review_item.review_major end,
@@ -113,6 +113,10 @@
 
   <update id="updateJudgment">
     update review_item set
+      has_attachment    = #{hasAttachment},
+      kogl_attached     = #{koglAttached},
+      kogl_type         = #{koglType},
+      ai_type           = #{aiType},
       openable          = #{openable},
       review_major      = #{reviewMajor},
       review_minor      = #{reviewMinor},
Add a comment
List