feat: 확정 요구사항 반영 (4·5·6·8·15·25)
[4][5] RE 메모 기관관리 RE 탭을 메모장처럼 만들었다. 메모 1개가 RE 1개다.
내용·작성자·일자에 완료 체크 한 칸을 더했고, 대시보드 '미해결 RE'가
이제 실제 숫자를 센다(예전에는 항상 0으로 고정돼 있었다).
완료 체크는 회신에 없던 항목이라 확인 요청해 둔 상태다.
[6] 업무구간 필터를 뺐다. 현재 단계와 겹친다는 지적을 반영했고 순서도 현재 단계를
앞으로 옮겼다. 구간 구분 자체는 칸반 컬럼 색에 계속 쓰이므로 남겨 둔다.
[8] 자료접수를 권리확인·권리처리 통합으로 바꿨다. 어느 쪽이든 한 건이라도 있으면 접수다.
[15] 일괄 등록에 비고를 더했다. 권리확인·권리처리 양쪽 모두.
[25] 보고서 교체 기능을 넣었다. 목록에 새 줄이 생기지 않고 그 자리만 바뀐다.
개정판으로 남기고 싶으면 지금처럼 새로 올리면 된다.
RE 메모의 해결 여부를 뒤집으면 대시보드 숫자가 실제로 따라 움직이는지까지 테스트한다.
백엔드 275건, 프론트 208건 통과.
Co-Authored-By: Claude Opus 5 (1M context)
@4dee05fbdb7ae41a372a881500fb37f7edc95151
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -171,6 +171,53 @@ |
| 171 | 171 |
return request<CodeMap>('/api/meta/codes')
|
| 172 | 172 |
} |
| 173 | 173 |
|
| 174 |
+/** RE 메모 한 건. 요구사항 [4]대로 메모 1개가 RE 1개다. */ |
|
| 175 |
+export interface ReMemo {
|
|
| 176 |
+ id: number |
|
| 177 |
+ orgId: number |
|
| 178 |
+ content: string |
|
| 179 |
+ author: string |
|
| 180 |
+ /** yyyy-MM-dd */ |
|
| 181 |
+ memoDate: string |
|
| 182 |
+ resolved: boolean |
|
| 183 |
+ createdAt: number |
|
| 184 |
+} |
|
| 185 |
+ |
|
| 186 |
+export interface ReMemoInput {
|
|
| 187 |
+ content: string |
|
| 188 |
+ author: string |
|
| 189 |
+ memoDate: string |
|
| 190 |
+ resolved: boolean |
|
| 191 |
+} |
|
| 192 |
+ |
|
| 193 |
+export function getReMemos(orgId: number): Promise<ReMemo[]> {
|
|
| 194 |
+ return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos`)
|
|
| 195 |
+} |
|
| 196 |
+ |
|
| 197 |
+export function addReMemo(orgId: number, body: ReMemoInput): Promise<ReMemo[]> {
|
|
| 198 |
+ return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos`, {
|
|
| 199 |
+ method: 'POST', |
|
| 200 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 201 |
+ body: JSON.stringify(body), |
|
| 202 |
+ }) |
|
| 203 |
+} |
|
| 204 |
+ |
|
| 205 |
+export function updateReMemo(orgId: number, memoId: number, body: ReMemoInput): Promise<ReMemo[]> {
|
|
| 206 |
+ return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos/${memoId}`, {
|
|
| 207 |
+ method: 'PUT', |
|
| 208 |
+ headers: { 'Content-Type': 'application/json' },
|
|
| 209 |
+ body: JSON.stringify(body), |
|
| 210 |
+ }) |
|
| 211 |
+} |
|
| 212 |
+ |
|
| 213 |
+export async function deleteReMemo(orgId: number, memoId: number): Promise<void> {
|
|
| 214 |
+ await fetch(`/api/orgs/${orgId}/re-memos/${memoId}`, {
|
|
| 215 |
+ method: 'DELETE', |
|
| 216 |
+ headers: { 'X-XSRF-TOKEN': csrfToken() },
|
|
| 217 |
+ credentials: 'same-origin', |
|
| 218 |
+ }) |
|
| 219 |
+} |
|
| 220 |
+ |
|
| 174 | 221 |
export function updateAssignments(orgId: number, body: AssignmentInput): Promise<Org> {
|
| 175 | 222 |
return request<Org>(`/api/orgs/${orgId}/assignments`, {
|
| 176 | 223 |
method: 'PUT', |
... | ... | @@ -717,7 +764,14 @@ |
| 717 | 764 |
/** 권리확인 일괄 등록. 비운 항목은 기존 값을 유지한다. */ |
| 718 | 765 |
export function bulkUpdateReview( |
| 719 | 766 |
orgId: number, |
| 720 |
- body: { ids: number[]; reviewResult?: string; judgedKoglType?: string; opinion?: string },
|
|
| 767 |
+ body: {
|
|
| 768 |
+ ids: number[] |
|
| 769 |
+ reviewResult?: string |
|
| 770 |
+ judgedKoglType?: string |
|
| 771 |
+ opinion?: string |
|
| 772 |
+ /** 요구사항 [15]로 추가된 비고. */ |
|
| 773 |
+ lawyerNote?: string |
|
| 774 |
+ }, |
|
| 721 | 775 |
): Promise<{ updated: number }> {
|
| 722 | 776 |
return request<{ updated: number }>(`/api/orgs/${orgId}/review/bulk`, {
|
| 723 | 777 |
method: 'PUT', |
... | ... | @@ -737,7 +791,14 @@ |
| 737 | 791 |
/** 권리처리 일괄 등록. 비운 항목은 기존 값을 유지한다. */ |
| 738 | 792 |
export function bulkUpdateProcess( |
| 739 | 793 |
orgId: number, |
| 740 |
- body: { ids: number[]; processStatus?: string; judgedKoglType?: string; finalOpinion?: string },
|
|
| 794 |
+ body: {
|
|
| 795 |
+ ids: number[] |
|
| 796 |
+ processStatus?: string |
|
| 797 |
+ judgedKoglType?: string |
|
| 798 |
+ finalOpinion?: string |
|
| 799 |
+ /** 요구사항 [15]로 추가된 비고. */ |
|
| 800 |
+ reviewNote?: string |
|
| 801 |
+ }, |
|
| 741 | 802 |
): Promise<{ updated: number }> {
|
| 742 | 803 |
return request<{ updated: number }>(`/api/orgs/${orgId}/process/bulk`, {
|
| 743 | 804 |
method: 'PUT', |
--- frontend/src/components/Dashboard.tsx
+++ frontend/src/components/Dashboard.tsx
... | ... | @@ -177,8 +177,7 @@ |
| 177 | 177 |
const [busy, setBusy] = useState(false) |
| 178 | 178 |
const [warning, setWarning] = useState<string | null>(null) |
| 179 | 179 |
|
| 180 |
- // 칸반 필터 |
|
| 181 |
- const [groupFilter, setGroupFilter] = useState('')
|
|
| 180 |
+ // 칸반 필터 (업무 구간은 요구사항 [6]으로 제거했다) |
|
| 182 | 181 |
const [lawyerFilter, setLawyerFilter] = useState('')
|
| 183 | 182 |
const [stageFilter, setStageFilter] = useState('')
|
| 184 | 183 |
const [statusFilter, setStatusFilter] = useState('')
|
... | ... | @@ -221,9 +220,6 @@ |
| 221 | 220 |
const keyword = boardKeyword.trim() |
| 222 | 221 |
return rows.filter((row) => {
|
| 223 | 222 |
const stage = columnOf(row) |
| 224 |
- if (groupFilter && stages.groupKey(stage) !== groupFilter) {
|
|
| 225 |
- return false |
|
| 226 |
- } |
|
| 227 | 223 |
if (lawyerFilter && row.lawyerName !== lawyerFilter) {
|
| 228 | 224 |
return false |
| 229 | 225 |
} |
... | ... | @@ -247,7 +243,7 @@ |
| 247 | 243 |
} |
| 248 | 244 |
return true |
| 249 | 245 |
}) |
| 250 |
- }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword, stages]) |
|
| 246 |
+ }, [rows, lawyerFilter, stageFilter, statusFilter, boardKeyword, stages]) |
|
| 251 | 247 |
|
| 252 | 248 |
/** 시안의 "정렬 기준 최근 진행순" - 칸반 카드도 최근 변동이 위로 온다. */ |
| 253 | 249 |
const byRecent = useCallback( |
... | ... | @@ -271,10 +267,13 @@ |
| 271 | 267 |
if (tableLawyer && row.lawyerName !== tableLawyer) {
|
| 272 | 268 |
return false |
| 273 | 269 |
} |
| 274 |
- if (tableData === 'received' && row.reviewTotal === 0) {
|
|
| 270 |
+ // 요구사항 [8]: 권리확인·권리처리를 구분하지 않는다. 어느 쪽이든 한 건이라도 |
|
| 271 |
+ // 올라와 있으면 접수로 본다. |
|
| 272 |
+ const received = row.reviewTotal + row.processTotal > 0 |
|
| 273 |
+ if (tableData === 'received' && !received) {
|
|
| 275 | 274 |
return false |
| 276 | 275 |
} |
| 277 |
- if (tableData === 'pending' && row.reviewTotal > 0) {
|
|
| 276 |
+ if (tableData === 'pending' && received) {
|
|
| 278 | 277 |
return false |
| 279 | 278 |
} |
| 280 | 279 |
return true |
... | ... | @@ -388,16 +387,21 @@ |
| 388 | 387 |
<HelpButton topic="dashboard.kanban" /> |
| 389 | 388 |
</h2> |
| 390 | 389 |
|
| 390 |
+ {/*
|
|
| 391 |
+ 요구사항 [6]: 업무 구간 필터는 뺐다. 현재 단계와 기능이 겹친다는 지적이 있었고, |
|
| 392 |
+ 순서도 현재 단계를 앞으로 옮겼다. 구간 구분 자체는 칸반 컬럼 색에 계속 쓰이므로 |
|
| 393 |
+ 코드표에는 그대로 남겨 둔다. |
|
| 394 |
+ */} |
|
| 391 | 395 |
<div className="mt-3 flex flex-wrap gap-2"> |
| 392 | 396 |
<select |
| 393 |
- aria-label="업무 구간" |
|
| 394 |
- value={groupFilter}
|
|
| 395 |
- onChange={(e) => setGroupFilter(e.target.value)}
|
|
| 397 |
+ aria-label="현재 단계" |
|
| 398 |
+ value={stageFilter}
|
|
| 399 |
+ onChange={(e) => setStageFilter(e.target.value)}
|
|
| 396 | 400 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 397 | 401 |
> |
| 398 |
- <option value="">업무 구간 전체</option> |
|
| 399 |
- {stages.groups.map((g) => (
|
|
| 400 |
- <option key={g.code} value={g.code}>{g.label}</option>
|
|
| 402 |
+ <option value="">현재 단계 전체</option> |
|
| 403 |
+ {stages.numbers.map((n) => (
|
|
| 404 |
+ <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
|
|
| 401 | 405 |
))} |
| 402 | 406 |
</select> |
| 403 | 407 |
<select |
... | ... | @@ -409,17 +413,6 @@ |
| 409 | 413 |
<option value="">담당 변호사 전체</option> |
| 410 | 414 |
{lawyers.map((n) => (
|
| 411 | 415 |
<option key={n} value={n}>{n}</option>
|
| 412 |
- ))} |
|
| 413 |
- </select> |
|
| 414 |
- <select |
|
| 415 |
- aria-label="현재 단계" |
|
| 416 |
- value={stageFilter}
|
|
| 417 |
- onChange={(e) => setStageFilter(e.target.value)}
|
|
| 418 |
- className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 419 |
- > |
|
| 420 |
- <option value="">현재 단계 전체</option> |
|
| 421 |
- {stages.numbers.map((n) => (
|
|
| 422 |
- <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
|
|
| 423 | 416 |
))} |
| 424 | 417 |
</select> |
| 425 | 418 |
<select |
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
... | ... | @@ -15,6 +15,7 @@ |
| 15 | 15 |
getStageHistory: vi.fn(), |
| 16 | 16 |
getReviewPage: vi.fn(), |
| 17 | 17 |
getProcessPage: vi.fn(), |
| 18 |
+ getReMemos: vi.fn(), |
|
| 18 | 19 |
})) |
| 19 | 20 |
|
| 20 | 21 |
vi.mock('../api/client', async (importOriginal) => ({
|
... | ... | @@ -29,6 +30,7 @@ |
| 29 | 30 |
getStageHistory: mocks.getStageHistory, |
| 30 | 31 |
getReviewPage: mocks.getReviewPage, |
| 31 | 32 |
getProcessPage: mocks.getProcessPage, |
| 33 |
+ getReMemos: mocks.getReMemos, |
|
| 32 | 34 |
})) |
| 33 | 35 |
|
| 34 | 36 |
function contact(overrides: Partial<Contact> = {}): Contact {
|
... | ... | @@ -282,14 +284,18 @@ |
| 282 | 284 |
expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
|
| 283 | 285 |
}) |
| 284 | 286 |
|
| 285 |
- it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
|
|
| 287 |
+ // 요구사항 [4][5]로 RE 탭에 실제 화면(메모장)이 생겼다. 자리표시가 아니다. |
|
| 288 |
+ it('RE 탭을 누르면 메모 입력 화면이 나온다', async () => {
|
|
| 289 |
+ mocks.getReMemos.mockResolvedValue([]) |
|
| 286 | 290 |
render(withCodes(<OrgOverview org={org()} />))
|
| 287 | 291 |
|
| 288 | 292 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 289 | 293 |
|
| 290 | 294 |
fireEvent.click(screen.getByRole('button', { name: 'RE' }))
|
| 291 | 295 |
|
| 292 |
- expect(screen.getByText('RE 상세 화면은 추후 확정')).toBeTruthy()
|
|
| 296 |
+ await waitFor(() => expect(mocks.getReMemos).toHaveBeenCalledWith(1)) |
|
| 297 |
+ expect(screen.getByLabelText('RE 내용')).toBeTruthy()
|
|
| 298 |
+ expect(screen.getByLabelText('작성자')).toBeTruthy()
|
|
| 293 | 299 |
expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
|
| 294 | 300 |
}) |
| 295 | 301 |
|
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
... | ... | @@ -17,6 +17,7 @@ |
| 17 | 17 |
import ChannelPosts from './ChannelPosts' |
| 18 | 18 |
import ContactPickerModal from './ContactPickerModal' |
| 19 | 19 |
import ProcessBoard from './ProcessBoard' |
| 20 |
+import ReMemos from './ReMemos' |
|
| 20 | 21 |
import ReviewBoard from './ReviewBoard' |
| 21 | 22 |
import StatusBadge from './StatusBadge' |
| 22 | 23 |
import Timeline from './Timeline' |
... | ... | @@ -28,7 +29,7 @@ |
| 28 | 29 |
|
| 29 | 30 |
const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모'] |
| 30 | 31 |
|
| 31 |
-/** 탭마다 그 탭 기능의 설명서를 띄운다. RE는 화면이 아직 없어 도움말도 없다. */ |
|
| 32 |
+/** 탭마다 그 탭 기능의 설명서를 띄운다. */ |
|
| 32 | 33 |
const TAB_HELP: Record<string, string> = {
|
| 33 | 34 |
'채팅': 'org.chat', |
| 34 | 35 |
'자료': 'org.files', |
... | ... | @@ -595,6 +596,10 @@ |
| 595 | 596 |
<div className="mt-4 rounded-lg border border-gray-200"> |
| 596 | 597 |
<Timeline key={org.id} org={org} />
|
| 597 | 598 |
</div> |
| 599 |
+ ) : activeTab === 'RE' ? ( |
|
| 600 |
+ <div className="mt-4 rounded-lg border border-gray-200"> |
|
| 601 |
+ <ReMemos key={org.id} org={org} />
|
|
| 602 |
+ </div> |
|
| 598 | 603 |
) : ( |
| 599 | 604 |
<div className="mt-4 rounded-lg border border-dashed border-gray-200 p-10 text-center text-sm text-gray-400"> |
| 600 | 605 |
{activeTab} 상세 화면은 추후 확정
|
--- frontend/src/components/ProcessBoard.tsx
+++ frontend/src/components/ProcessBoard.tsx
... | ... | @@ -218,6 +218,8 @@ |
| 218 | 218 |
const [bulkStatus, setBulkStatus] = useState('')
|
| 219 | 219 |
const [bulkKoglType, setBulkKoglType] = useState('')
|
| 220 | 220 |
const [bulkOpinion, setBulkOpinion] = useState('')
|
| 221 |
+ /** 요구사항 [15]로 추가된 비고 일괄 입력. */ |
|
| 222 |
+ const [bulkNote, setBulkNote] = useState('')
|
|
| 221 | 223 |
const [bulkBusy, setBulkBusy] = useState(false) |
| 222 | 224 |
const [bulkMessage, setBulkMessage] = useState<string | null>(null) |
| 223 | 225 |
const [loading, setLoading] = useState(false) |
... | ... | @@ -325,7 +327,7 @@ |
| 325 | 327 |
if (selected.length === 0 || bulkBusy) {
|
| 326 | 328 |
return |
| 327 | 329 |
} |
| 328 |
- if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim()) {
|
|
| 330 |
+ if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim() && !bulkNote.trim()) {
|
|
| 329 | 331 |
setBulkMessage('반영할 값을 하나 이상 채워주세요.')
|
| 330 | 332 |
return |
| 331 | 333 |
} |
... | ... | @@ -337,12 +339,14 @@ |
| 337 | 339 |
processStatus: bulkStatus || undefined, |
| 338 | 340 |
judgedKoglType: bulkKoglType || undefined, |
| 339 | 341 |
finalOpinion: bulkOpinion.trim() || undefined, |
| 342 |
+ reviewNote: bulkNote.trim() || undefined, |
|
| 340 | 343 |
}) |
| 341 | 344 |
setBulkMessage(`${res.updated}건에 반영했습니다.`)
|
| 342 | 345 |
setSelected([]) |
| 343 | 346 |
setBulkStatus('')
|
| 344 | 347 |
setBulkKoglType('')
|
| 345 | 348 |
setBulkOpinion('')
|
| 349 |
+ setBulkNote('')
|
|
| 346 | 350 |
setReloadKey((k) => k + 1) |
| 347 | 351 |
} catch (e) {
|
| 348 | 352 |
setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.') |
... | ... | @@ -552,6 +556,14 @@ |
| 552 | 556 |
placeholder="최종의견(비우면 유지)" |
| 553 | 557 |
className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 554 | 558 |
/> |
| 559 |
+ <input |
|
| 560 |
+ type="text" |
|
| 561 |
+ aria-label="일괄 비고" |
|
| 562 |
+ value={bulkNote}
|
|
| 563 |
+ onChange={(e) => setBulkNote(e.target.value)}
|
|
| 564 |
+ placeholder="비고(비우면 유지)" |
|
| 565 |
+ className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 566 |
+ /> |
|
| 555 | 567 |
<button |
| 556 | 568 |
type="button" |
| 557 | 569 |
disabled={selected.length === 0 || bulkBusy}
|
+++ frontend/src/components/ReMemos.tsx
... | ... | @@ -0,0 +1,231 @@ |
| 1 | +import { useCallback, useEffect, useState } from 'react' | |
| 2 | +import { | |
| 3 | + addReMemo, | |
| 4 | + deleteReMemo, | |
| 5 | + getReMemos, | |
| 6 | + updateReMemo, | |
| 7 | + type Org, | |
| 8 | + type ReMemo, | |
| 9 | +} from '../api/client' | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 기관관리 상세의 RE 탭. | |
| 13 | + * | |
| 14 | + * 요구사항 [4]: 별도 양식 없이 메모장처럼 등록한다. 메모 1개가 RE 1개다. | |
| 15 | + * 예) ooo 계약서 및 제안요청서 송부 요청, 장영익, 2026.07.08 | |
| 16 | + * | |
| 17 | + * 완료 체크는 회신에 없던 항목이다. 대시보드 '미해결 RE' 숫자를 세려면 해결된 건과 아닌 건을 | |
| 18 | + * 가릴 수단이 필요해 한 칸 두었다. | |
| 19 | + */ | |
| 20 | +function today(): string { | |
| 21 | + const d = new Date() | |
| 22 | + const pad = (n: number) => String(n).padStart(2, '0') | |
| 23 | + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` | |
| 24 | +} | |
| 25 | + | |
| 26 | +export default function ReMemos({ org }: { org: Org }) { | |
| 27 | + const [memos, setMemos] = useState<ReMemo[]>([]) | |
| 28 | + const [loading, setLoading] = useState(true) | |
| 29 | + const [error, setError] = useState<string | null>(null) | |
| 30 | + const [busy, setBusy] = useState(false) | |
| 31 | + | |
| 32 | + const [content, setContent] = useState('') | |
| 33 | + const [author, setAuthor] = useState('') | |
| 34 | + const [memoDate, setMemoDate] = useState(today()) | |
| 35 | + const [editingId, setEditingId] = useState<number | null>(null) | |
| 36 | + | |
| 37 | + const load = useCallback(async () => { | |
| 38 | + setLoading(true) | |
| 39 | + try { | |
| 40 | + setMemos(await getReMemos(org.id)) | |
| 41 | + setError(null) | |
| 42 | + } catch (e) { | |
| 43 | + setError((e as Error).message) | |
| 44 | + } finally { | |
| 45 | + setLoading(false) | |
| 46 | + } | |
| 47 | + }, [org.id]) | |
| 48 | + | |
| 49 | + useEffect(() => { | |
| 50 | + void load() | |
| 51 | + }, [load]) | |
| 52 | + | |
| 53 | + function resetForm() { | |
| 54 | + setContent('') | |
| 55 | + setAuthor('') | |
| 56 | + setMemoDate(today()) | |
| 57 | + setEditingId(null) | |
| 58 | + } | |
| 59 | + | |
| 60 | + async function handleSave() { | |
| 61 | + if (!content.trim() || !author.trim() || busy) { | |
| 62 | + return | |
| 63 | + } | |
| 64 | + setBusy(true) | |
| 65 | + setError(null) | |
| 66 | + try { | |
| 67 | + const body = { content: content.trim(), author: author.trim(), memoDate, resolved: false } | |
| 68 | + const next = editingId === null | |
| 69 | + ? await addReMemo(org.id, body) | |
| 70 | + : await updateReMemo(org.id, editingId, { | |
| 71 | + ...body, | |
| 72 | + resolved: memos.find((m) => m.id === editingId)?.resolved ?? false, | |
| 73 | + }) | |
| 74 | + setMemos(next) | |
| 75 | + resetForm() | |
| 76 | + } catch (e) { | |
| 77 | + setError((e as Error).message) | |
| 78 | + } finally { | |
| 79 | + setBusy(false) | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + async function toggleResolved(memo: ReMemo) { | |
| 84 | + setBusy(true) | |
| 85 | + try { | |
| 86 | + setMemos(await updateReMemo(org.id, memo.id, { | |
| 87 | + content: memo.content, | |
| 88 | + author: memo.author, | |
| 89 | + memoDate: memo.memoDate, | |
| 90 | + resolved: !memo.resolved, | |
| 91 | + })) | |
| 92 | + } catch (e) { | |
| 93 | + setError((e as Error).message) | |
| 94 | + } finally { | |
| 95 | + setBusy(false) | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + async function handleDelete(memo: ReMemo) { | |
| 100 | + if (!window.confirm('이 RE를 지울까요?')) { | |
| 101 | + return | |
| 102 | + } | |
| 103 | + setBusy(true) | |
| 104 | + try { | |
| 105 | + await deleteReMemo(org.id, memo.id) | |
| 106 | + await load() | |
| 107 | + if (editingId === memo.id) { | |
| 108 | + resetForm() | |
| 109 | + } | |
| 110 | + } finally { | |
| 111 | + setBusy(false) | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + function startEdit(memo: ReMemo) { | |
| 116 | + setEditingId(memo.id) | |
| 117 | + setContent(memo.content) | |
| 118 | + setAuthor(memo.author) | |
| 119 | + setMemoDate(memo.memoDate) | |
| 120 | + } | |
| 121 | + | |
| 122 | + const unresolved = memos.filter((m) => !m.resolved).length | |
| 123 | + | |
| 124 | + return ( | |
| 125 | + <div className="p-5"> | |
| 126 | + <div className="flex items-center justify-between"> | |
| 127 | + <h3 className="text-sm font-semibold"> | |
| 128 | + RE <span className="ml-1 text-xs font-normal text-gray-500">미해결 {unresolved}건 / 전체 {memos.length}건</span> | |
| 129 | + </h3> | |
| 130 | + </div> | |
| 131 | + | |
| 132 | + <div className="mt-3 rounded-lg border border-gray-200 p-3"> | |
| 133 | + <textarea | |
| 134 | + aria-label="RE 내용" | |
| 135 | + value={content} | |
| 136 | + onChange={(e) => setContent(e.target.value)} | |
| 137 | + rows={2} | |
| 138 | + placeholder="예) ooo 계약서 및 제안요청서 송부 요청" | |
| 139 | + className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 140 | + /> | |
| 141 | + <div className="mt-2 flex flex-wrap items-center gap-2"> | |
| 142 | + <input | |
| 143 | + aria-label="작성자" | |
| 144 | + value={author} | |
| 145 | + onChange={(e) => setAuthor(e.target.value)} | |
| 146 | + placeholder="작성자" | |
| 147 | + className="w-32 rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 148 | + /> | |
| 149 | + <input | |
| 150 | + type="date" | |
| 151 | + aria-label="일자" | |
| 152 | + value={memoDate} | |
| 153 | + onChange={(e) => setMemoDate(e.target.value)} | |
| 154 | + className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" | |
| 155 | + /> | |
| 156 | + <button | |
| 157 | + type="button" | |
| 158 | + disabled={!content.trim() || !author.trim() || busy} | |
| 159 | + onClick={() => void handleSave()} | |
| 160 | + className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300" | |
| 161 | + > | |
| 162 | + {editingId === null ? '등록' : '수정'} | |
| 163 | + </button> | |
| 164 | + {editingId !== null && ( | |
| 165 | + <button | |
| 166 | + type="button" | |
| 167 | + onClick={resetForm} | |
| 168 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600" | |
| 169 | + > | |
| 170 | + 취소 | |
| 171 | + </button> | |
| 172 | + )} | |
| 173 | + </div> | |
| 174 | + </div> | |
| 175 | + | |
| 176 | + {error && <p className="mt-2 text-sm text-red-600">{error}</p>} | |
| 177 | + | |
| 178 | + {loading ? ( | |
| 179 | + <p className="mt-4 text-sm text-gray-500">불러오는 중…</p> | |
| 180 | + ) : memos.length === 0 ? ( | |
| 181 | + <p className="mt-4 text-sm text-gray-500">등록된 RE가 없습니다.</p> | |
| 182 | + ) : ( | |
| 183 | + <ul className="mt-4 space-y-2"> | |
| 184 | + {memos.map((memo) => ( | |
| 185 | + <li | |
| 186 | + key={memo.id} | |
| 187 | + className={`rounded-lg border p-3 ${ | |
| 188 | + memo.resolved ? 'border-gray-200 bg-gray-50' : 'border-amber-200 bg-amber-50/40' | |
| 189 | + }`} | |
| 190 | + > | |
| 191 | + <div className="flex items-start justify-between gap-3"> | |
| 192 | + <p className={`whitespace-pre-wrap text-sm ${memo.resolved ? 'text-gray-400 line-through' : 'text-gray-900'}`}> | |
| 193 | + {memo.content} | |
| 194 | + </p> | |
| 195 | + <div className="flex shrink-0 gap-2"> | |
| 196 | + <button | |
| 197 | + type="button" | |
| 198 | + disabled={busy} | |
| 199 | + onClick={() => void toggleResolved(memo)} | |
| 200 | + className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" | |
| 201 | + > | |
| 202 | + {memo.resolved ? '미해결로' : '해결'} | |
| 203 | + </button> | |
| 204 | + <button | |
| 205 | + type="button" | |
| 206 | + disabled={busy} | |
| 207 | + onClick={() => startEdit(memo)} | |
| 208 | + className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50" | |
| 209 | + > | |
| 210 | + 수정 | |
| 211 | + </button> | |
| 212 | + <button | |
| 213 | + type="button" | |
| 214 | + disabled={busy} | |
| 215 | + onClick={() => void handleDelete(memo)} | |
| 216 | + className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-red-600 hover:bg-red-50" | |
| 217 | + > | |
| 218 | + 삭제 | |
| 219 | + </button> | |
| 220 | + </div> | |
| 221 | + </div> | |
| 222 | + <p className="mt-1.5 text-xs text-gray-500"> | |
| 223 | + {memo.author} · {memo.memoDate} | |
| 224 | + </p> | |
| 225 | + </li> | |
| 226 | + ))} | |
| 227 | + </ul> | |
| 228 | + )} | |
| 229 | + </div> | |
| 230 | + ) | |
| 231 | +} |
--- frontend/src/components/ReviewBoard.tsx
+++ frontend/src/components/ReviewBoard.tsx
... | ... | @@ -202,6 +202,8 @@ |
| 202 | 202 |
const [bulkResult, setBulkResult] = useState('')
|
| 203 | 203 |
const [bulkKoglType, setBulkKoglType] = useState('')
|
| 204 | 204 |
const [bulkOpinion, setBulkOpinion] = useState('')
|
| 205 |
+ /** 요구사항 [15]로 추가된 비고 일괄 입력. */ |
|
| 206 |
+ const [bulkNote, setBulkNote] = useState('')
|
|
| 205 | 207 |
const [bulkBusy, setBulkBusy] = useState(false) |
| 206 | 208 |
const [bulkMessage, setBulkMessage] = useState<string | null>(null) |
| 207 | 209 |
const [loading, setLoading] = useState(false) |
... | ... | @@ -285,7 +287,7 @@ |
| 285 | 287 |
if (selected.length === 0 || bulkBusy) {
|
| 286 | 288 |
return |
| 287 | 289 |
} |
| 288 |
- if (!bulkResult && !bulkKoglType && !bulkOpinion.trim()) {
|
|
| 290 |
+ if (!bulkResult && !bulkKoglType && !bulkOpinion.trim() && !bulkNote.trim()) {
|
|
| 289 | 291 |
setBulkMessage('반영할 값을 하나 이상 채워주세요.')
|
| 290 | 292 |
return |
| 291 | 293 |
} |
... | ... | @@ -297,12 +299,14 @@ |
| 297 | 299 |
reviewResult: bulkResult || undefined, |
| 298 | 300 |
judgedKoglType: bulkKoglType || undefined, |
| 299 | 301 |
opinion: bulkOpinion.trim() || undefined, |
| 302 |
+ lawyerNote: bulkNote.trim() || undefined, |
|
| 300 | 303 |
}) |
| 301 | 304 |
setBulkMessage(`${res.updated}건에 반영했습니다.`)
|
| 302 | 305 |
setSelected([]) |
| 303 | 306 |
setBulkResult('')
|
| 304 | 307 |
setBulkKoglType('')
|
| 305 | 308 |
setBulkOpinion('')
|
| 309 |
+ setBulkNote('')
|
|
| 306 | 310 |
setReloadKey((k) => k + 1) |
| 307 | 311 |
} catch (e) {
|
| 308 | 312 |
setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.') |
... | ... | @@ -507,6 +511,14 @@ |
| 507 | 511 |
placeholder="의견(비우면 유지)" |
| 508 | 512 |
className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 509 | 513 |
/> |
| 514 |
+ <input |
|
| 515 |
+ type="text" |
|
| 516 |
+ aria-label="일괄 비고" |
|
| 517 |
+ value={bulkNote}
|
|
| 518 |
+ onChange={(e) => setBulkNote(e.target.value)}
|
|
| 519 |
+ placeholder="비고(비우면 유지)" |
|
| 520 |
+ className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
|
| 521 |
+ /> |
|
| 510 | 522 |
<button |
| 511 | 523 |
type="button" |
| 512 | 524 |
disabled={selected.length === 0 || bulkBusy}
|
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 |
import kr.itn.itnhub.org.OrgNotFoundException; |
| 8 | 8 |
import kr.itn.itnhub.process.InvalidProcessStatusException; |
| 9 | 9 |
import kr.itn.itnhub.process.ProcessNotFoundException; |
| 10 |
+import kr.itn.itnhub.re.ReMemoNotFoundException; |
|
| 10 | 11 |
import kr.itn.itnhub.review.ReviewNotFoundException; |
| 11 | 12 |
import kr.itn.itnhub.seed.SeedParseException; |
| 12 | 13 |
import kr.itn.itnhub.stage.InvalidStageException; |
... | ... | @@ -85,6 +86,11 @@ |
| 85 | 86 |
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage())); |
| 86 | 87 |
} |
| 87 | 88 |
|
| 89 |
+ @ExceptionHandler(ReMemoNotFoundException.class) |
|
| 90 |
+ public ResponseEntity<ApiError> handleReMemoNotFound(ReMemoNotFoundException e) {
|
|
| 91 |
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage())); |
|
| 92 |
+ } |
|
| 93 |
+ |
|
| 88 | 94 |
@ExceptionHandler(MethodArgumentNotValidException.class) |
| 89 | 95 |
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException e) {
|
| 90 | 96 |
String fields = e.getBindingResult().getFieldErrors().stream() |
--- src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
+++ src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
... | ... | @@ -10,5 +10,7 @@ |
| 10 | 10 |
List<Long> ids, |
| 11 | 11 |
String processStatus, |
| 12 | 12 |
String judgedKoglType, |
| 13 |
- String finalOpinion) {
|
|
| 13 |
+ String finalOpinion, |
|
| 14 |
+ /** 요구사항 [15]로 추가된 항목. 화면의 '비고'와 같은 칸이다. */ |
|
| 15 |
+ String reviewNote) {
|
|
| 14 | 16 |
} |
--- src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
+++ src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
... | ... | @@ -38,6 +38,7 @@ |
| 38 | 38 |
@Param("processStatus") String processStatus,
|
| 39 | 39 |
@Param("judgedKoglType") String judgedKoglType,
|
| 40 | 40 |
@Param("finalOpinion") String finalOpinion,
|
| 41 |
+ @Param("reviewNote") String reviewNote,
|
|
| 41 | 42 |
@Param("doneStatus") String doneStatus);
|
| 42 | 43 |
|
| 43 | 44 |
/** 일괄 삭제. */ |
--- src/main/java/kr/itn/itnhub/process/ProcessService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessService.java
| Binary file is not shown |
--- src/main/java/kr/itn/itnhub/project/ProjectController.java
+++ src/main/java/kr/itn/itnhub/project/ProjectController.java
... | ... | @@ -95,6 +95,17 @@ |
| 95 | 95 |
.body(report.content()); |
| 96 | 96 |
} |
| 97 | 97 |
|
| 98 |
+ /** 올려둔 보고서를 새 파일로 바꾼다(요구사항 [25]). 목록에 새 줄이 생기지 않는다. */ |
|
| 99 |
+ @PutMapping("/api/orgs/{id}/reports/{reportId}")
|
|
| 100 |
+ public List<OrgReport> replaceReport(@PathVariable("id") Long orgId,
|
|
| 101 |
+ @PathVariable Long reportId, |
|
| 102 |
+ @RequestPart("file") MultipartFile file,
|
|
| 103 |
+ Principal principal) throws IOException {
|
|
| 104 |
+ String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename(); |
|
| 105 |
+ return projectService.replaceReport(orgId, reportId, name, file.getContentType(), |
|
| 106 |
+ file.getBytes(), principal.getName()); |
|
| 107 |
+ } |
|
| 108 |
+ |
|
| 98 | 109 |
@ResponseStatus(HttpStatus.NO_CONTENT) |
| 99 | 110 |
@DeleteMapping("/api/orgs/{id}/reports/{reportId}")
|
| 100 | 111 |
public void deleteReport(@PathVariable("id") Long orgId, @PathVariable Long reportId) {
|
--- src/main/java/kr/itn/itnhub/project/ProjectMapper.java
+++ src/main/java/kr/itn/itnhub/project/ProjectMapper.java
... | ... | @@ -29,6 +29,12 @@ |
| 29 | 29 |
@Param("contentType") String contentType, @Param("byteSize") long byteSize,
|
| 30 | 30 |
@Param("content") byte[] content, @Param("uploadedBy") String uploadedBy);
|
| 31 | 31 |
|
| 32 |
+ /** 보고서 교체(요구사항 [25]). 새 행을 만들지 않고 같은 자리를 덮어쓴다. */ |
|
| 33 |
+ int updateReport(@Param("orgId") Long orgId, @Param("id") Long id,
|
|
| 34 |
+ @Param("fileName") String fileName,
|
|
| 35 |
+ @Param("contentType") String contentType, @Param("byteSize") long byteSize,
|
|
| 36 |
+ @Param("content") byte[] content, @Param("uploadedBy") String uploadedBy);
|
|
| 37 |
+ |
|
| 32 | 38 |
/** 다운로드용. 파일 내용까지 읽는다. */ |
| 33 | 39 |
ReportContent findReportContent(@Param("orgId") Long orgId, @Param("id") Long id);
|
| 34 | 40 |
|
--- src/main/java/kr/itn/itnhub/project/ProjectService.java
+++ src/main/java/kr/itn/itnhub/project/ProjectService.java
... | ... | @@ -99,6 +99,25 @@ |
| 99 | 99 |
return mapper.findReportsByOrg(orgId); |
| 100 | 100 |
} |
| 101 | 101 |
|
| 102 |
+ /** |
|
| 103 |
+ * 올려둔 보고서를 새 파일로 바꾼다(요구사항 [25]). 목록에 새 줄이 생기지 않고 그 자리만 |
|
| 104 |
+ * 바뀐다 - 개정판으로 남기고 싶으면 새로 올리면 된다. |
|
| 105 |
+ */ |
|
| 106 |
+ @Transactional |
|
| 107 |
+ public List<OrgReport> replaceReport(Long orgId, Long reportId, String fileName, |
|
| 108 |
+ String contentType, byte[] content, String uploadedBy) {
|
|
| 109 |
+ requireOrg(orgId); |
|
| 110 |
+ if (content == null || content.length == 0) {
|
|
| 111 |
+ throw new IllegalArgumentException("빈 파일은 올릴 수 없습니다.");
|
|
| 112 |
+ } |
|
| 113 |
+ int updated = mapper.updateReport(orgId, reportId, fileName, contentType, content.length, |
|
| 114 |
+ content, uploadedBy); |
|
| 115 |
+ if (updated == 0) {
|
|
| 116 |
+ throw new OrgNotFoundException("보고서를 찾을 수 없습니다: " + reportId);
|
|
| 117 |
+ } |
|
| 118 |
+ return mapper.findReportsByOrg(orgId); |
|
| 119 |
+ } |
|
| 120 |
+ |
|
| 102 | 121 |
public ProjectMapper.ReportContent reportContent(Long orgId, Long reportId) {
|
| 103 | 122 |
requireOrg(orgId); |
| 104 | 123 |
ProjectMapper.ReportContent content = mapper.findReportContent(orgId, reportId); |
+++ src/main/java/kr/itn/itnhub/re/ReMemo.java
... | ... | @@ -0,0 +1,18 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * RE 메모 한 건. 요구사항 [4]대로 "메모 1개 = RE 1개"다. | |
| 5 | + * | |
| 6 | + * <p>예) 내용 "ooo 계약서 및 제안요청서 송부 요청" / 작성자 "장영익" / 일자 2026-07-08</p> | |
| 7 | + * | |
| 8 | + * @param resolved 해결 여부. 대시보드의 '미해결 RE'는 이 값이 false인 건수다. | |
| 9 | + */ | |
| 10 | +public record ReMemo( | |
| 11 | + Long id, | |
| 12 | + Long orgId, | |
| 13 | + String content, | |
| 14 | + String author, | |
| 15 | + String memoDate, | |
| 16 | + boolean resolved, | |
| 17 | + Long createdAt) { | |
| 18 | +} |
+++ src/main/java/kr/itn/itnhub/re/ReMemoController.java
... | ... | @@ -0,0 +1,48 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +import jakarta.validation.Valid; | |
| 4 | +import org.springframework.http.HttpStatus; | |
| 5 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 6 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 7 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 8 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 9 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 10 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 11 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 12 | +import org.springframework.web.bind.annotation.RestController; | |
| 13 | + | |
| 14 | +import java.util.List; | |
| 15 | + | |
| 16 | +/** 기관관리 상세의 RE 탭. 등록·수정·삭제 후에는 갱신된 목록을 그대로 돌려준다. */ | |
| 17 | +@RestController | |
| 18 | +public class ReMemoController { | |
| 19 | + | |
| 20 | + private final ReMemoService service; | |
| 21 | + | |
| 22 | + public ReMemoController(ReMemoService service) { | |
| 23 | + this.service = service; | |
| 24 | + } | |
| 25 | + | |
| 26 | + @GetMapping("/api/orgs/{id}/re-memos") | |
| 27 | + public List<ReMemo> list(@PathVariable("id") Long orgId) { | |
| 28 | + return service.list(orgId); | |
| 29 | + } | |
| 30 | + | |
| 31 | + @PostMapping("/api/orgs/{id}/re-memos") | |
| 32 | + public List<ReMemo> add(@PathVariable("id") Long orgId, | |
| 33 | + @Valid @RequestBody ReMemoRequest request) { | |
| 34 | + return service.add(orgId, request); | |
| 35 | + } | |
| 36 | + | |
| 37 | + @PutMapping("/api/orgs/{id}/re-memos/{memoId}") | |
| 38 | + public List<ReMemo> update(@PathVariable("id") Long orgId, @PathVariable Long memoId, | |
| 39 | + @Valid @RequestBody ReMemoRequest request) { | |
| 40 | + return service.update(orgId, memoId, request); | |
| 41 | + } | |
| 42 | + | |
| 43 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 44 | + @DeleteMapping("/api/orgs/{id}/re-memos/{memoId}") | |
| 45 | + public void delete(@PathVariable("id") Long orgId, @PathVariable Long memoId) { | |
| 46 | + service.delete(orgId, memoId); | |
| 47 | + } | |
| 48 | +} |
+++ src/main/java/kr/itn/itnhub/re/ReMemoMapper.java
... | ... | @@ -0,0 +1,28 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | +import org.apache.ibatis.annotations.Param; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +@Mapper | |
| 9 | +public interface ReMemoMapper { | |
| 10 | + | |
| 11 | + /** 기관의 RE 메모를 일자 최신순으로. */ | |
| 12 | + List<ReMemo> findByOrg(@Param("orgId") Long orgId); | |
| 13 | + | |
| 14 | + ReMemo findById(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 15 | + | |
| 16 | + int insert(@Param("orgId") Long orgId, @Param("content") String content, | |
| 17 | + @Param("author") String author, @Param("memoDate") String memoDate, | |
| 18 | + @Param("resolved") boolean resolved); | |
| 19 | + | |
| 20 | + int update(@Param("orgId") Long orgId, @Param("id") Long id, | |
| 21 | + @Param("content") String content, @Param("author") String author, | |
| 22 | + @Param("memoDate") String memoDate, @Param("resolved") boolean resolved); | |
| 23 | + | |
| 24 | + int delete(@Param("orgId") Long orgId, @Param("id") Long id); | |
| 25 | + | |
| 26 | + /** 채널 초기화 시 함께 지운다 - 기관 데이터가 되돌아가는데 메모만 남으면 어긋난다. */ | |
| 27 | + int deleteByOrg(@Param("orgId") Long orgId); | |
| 28 | +} |
+++ src/main/java/kr/itn/itnhub/re/ReMemoNotFoundException.java
... | ... | @@ -0,0 +1,9 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +/** 없는 RE 메모를 고치거나 지우려 할 때. GlobalExceptionHandler가 404로 바꾼다. */ | |
| 4 | +public class ReMemoNotFoundException extends RuntimeException { | |
| 5 | + | |
| 6 | + public ReMemoNotFoundException(String message) { | |
| 7 | + super(message); | |
| 8 | + } | |
| 9 | +} |
+++ src/main/java/kr/itn/itnhub/re/ReMemoRequest.java
... | ... | @@ -0,0 +1,20 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.Size; | |
| 5 | + | |
| 6 | +/** RE 메모 등록/수정 입력. 일자를 비우면 오늘로 넣는다. */ | |
| 7 | +public record ReMemoRequest( | |
| 8 | + @NotBlank(message = "내용을 입력하세요.") | |
| 9 | + @Size(max = 2000, message = "내용은 2000자까지 넣을 수 있습니다.") | |
| 10 | + String content, | |
| 11 | + | |
| 12 | + @NotBlank(message = "작성자를 입력하세요.") | |
| 13 | + @Size(max = 100, message = "작성자는 100자까지 넣을 수 있습니다.") | |
| 14 | + String author, | |
| 15 | + | |
| 16 | + /** yyyy-MM-dd. 비우면 오늘. */ | |
| 17 | + String memoDate, | |
| 18 | + | |
| 19 | + boolean resolved) { | |
| 20 | +} |
+++ src/main/java/kr/itn/itnhub/re/ReMemoService.java
... | ... | @@ -0,0 +1,67 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.OrgNotFoundException; | |
| 4 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 5 | +import org.springframework.stereotype.Service; | |
| 6 | +import org.springframework.transaction.annotation.Transactional; | |
| 7 | + | |
| 8 | +import java.util.List; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 기관관리 상세의 RE 탭이 쓰는 서비스. | |
| 12 | + * | |
| 13 | + * <p>요구사항 [4]: 별도 양식 없이 메모장처럼 등록한다. 메모 1개가 RE 1개다.</p> | |
| 14 | + */ | |
| 15 | +@Service | |
| 16 | +public class ReMemoService { | |
| 17 | + | |
| 18 | + private final ReMemoMapper mapper; | |
| 19 | + private final OrganizationMapper orgMapper; | |
| 20 | + | |
| 21 | + public ReMemoService(ReMemoMapper mapper, OrganizationMapper orgMapper) { | |
| 22 | + this.mapper = mapper; | |
| 23 | + this.orgMapper = orgMapper; | |
| 24 | + } | |
| 25 | + | |
| 26 | + public List<ReMemo> list(Long orgId) { | |
| 27 | + requireOrg(orgId); | |
| 28 | + return mapper.findByOrg(orgId); | |
| 29 | + } | |
| 30 | + | |
| 31 | + @Transactional | |
| 32 | + public List<ReMemo> add(Long orgId, ReMemoRequest request) { | |
| 33 | + requireOrg(orgId); | |
| 34 | + mapper.insert(orgId, request.content().trim(), request.author().trim(), | |
| 35 | + blankToNull(request.memoDate()), request.resolved()); | |
| 36 | + return mapper.findByOrg(orgId); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Transactional | |
| 40 | + public List<ReMemo> update(Long orgId, Long memoId, ReMemoRequest request) { | |
| 41 | + requireOrg(orgId); | |
| 42 | + int updated = mapper.update(orgId, memoId, request.content().trim(), | |
| 43 | + request.author().trim(), blankToNull(request.memoDate()), request.resolved()); | |
| 44 | + if (updated == 0) { | |
| 45 | + throw new ReMemoNotFoundException("RE 메모를 찾을 수 없습니다: " + memoId); | |
| 46 | + } | |
| 47 | + return mapper.findByOrg(orgId); | |
| 48 | + } | |
| 49 | + | |
| 50 | + @Transactional | |
| 51 | + public void delete(Long orgId, Long memoId) { | |
| 52 | + requireOrg(orgId); | |
| 53 | + if (mapper.delete(orgId, memoId) == 0) { | |
| 54 | + throw new ReMemoNotFoundException("RE 메모를 찾을 수 없습니다: " + memoId); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + private String blankToNull(String value) { | |
| 59 | + return (value == null || value.isBlank()) ? null : value.trim(); | |
| 60 | + } | |
| 61 | + | |
| 62 | + private void requireOrg(Long orgId) { | |
| 63 | + if (orgMapper.findById(orgId) == null) { | |
| 64 | + throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId); | |
| 65 | + } | |
| 66 | + } | |
| 67 | +} |
--- src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
+++ src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
... | ... | @@ -12,5 +12,7 @@ |
| 12 | 12 |
List<Long> ids, |
| 13 | 13 |
String reviewResult, |
| 14 | 14 |
String judgedKoglType, |
| 15 |
- String opinion) {
|
|
| 15 |
+ String opinion, |
|
| 16 |
+ /** 요구사항 [15]로 추가된 항목. 화면의 '비고(변호사)'와 같은 칸이다. */ |
|
| 17 |
+ String lawyerNote) {
|
|
| 16 | 18 |
} |
--- src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
+++ src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
... | ... | @@ -29,7 +29,8 @@ |
| 29 | 29 |
int updateJudgmentBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
|
| 30 | 30 |
@Param("reviewResult") String reviewResult,
|
| 31 | 31 |
@Param("judgedKoglType") String judgedKoglType,
|
| 32 |
- @Param("opinion") String opinion);
|
|
| 32 |
+ @Param("opinion") String opinion,
|
|
| 33 |
+ @Param("lawyerNote") String lawyerNote);
|
|
| 33 | 34 |
|
| 34 | 35 |
/** 일괄 삭제. 목록에서 여러 건을 골라 지울 때 쓴다. */ |
| 35 | 36 |
int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
|
--- src/main/java/kr/itn/itnhub/review/ReviewService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewService.java
... | ... | @@ -65,7 +65,8 @@ |
| 65 | 65 |
return mapper.updateJudgmentBulk(orgId, request.ids(), |
| 66 | 66 |
blankToNull(request.reviewResult()), |
| 67 | 67 |
blankToNull(request.judgedKoglType()), |
| 68 |
- blankToNull(request.opinion())); |
|
| 68 |
+ blankToNull(request.opinion()), |
|
| 69 |
+ blankToNull(request.lawyerNote())); |
|
| 69 | 70 |
} |
| 70 | 71 |
|
| 71 | 72 |
/** 목록에서 고른 여러 건을 지운다. */ |
+++ src/main/resources/db/migration/V16__re_memo.sql
... | ... | @@ -0,0 +1,24 @@ |
| 1 | +-- RE 메모(요구사항 [4][5]). | |
| 2 | +-- | |
| 3 | +-- 회신 내용: "별도 양식까진 필요없고, 기관관리 메뉴의 RE 메뉴에서 메모장처럼 등록하는 | |
| 4 | +-- 기능을 만들어 주시면 됩니다. 메모 1개당 RE 1개입니다." | |
| 5 | +-- 예) ooo 계약서 및 제안요청서 송부 요청, 장영익, 2026.07.08 | |
| 6 | +-- | |
| 7 | +-- resolved(해결 여부)는 회신에 없던 항목이다. 대시보드 '미해결 RE' 숫자를 세려면 해결된 | |
| 8 | +-- 건과 아닌 건을 가릴 수단이 있어야 해서 완료 체크 한 칸으로 두었다. 확인 요청해 둔 상태다. | |
| 9 | + | |
| 10 | +create table re_memo ( | |
| 11 | + id bigserial primary key, | |
| 12 | + org_id bigint not null references organization (id) on delete cascade, | |
| 13 | + content text not null, | |
| 14 | + author varchar(100) not null, | |
| 15 | + memo_date date not null, | |
| 16 | + resolved boolean not null default false, | |
| 17 | + resolved_at timestamptz, | |
| 18 | + created_at timestamptz not null default now(), | |
| 19 | + updated_at timestamptz not null default now() | |
| 20 | +); | |
| 21 | + | |
| 22 | +-- 기관별 최신순 조회와 미해결 건수 집계를 함께 받는 인덱스. | |
| 23 | +create index ix_re_memo_org on re_memo (org_id, memo_date desc, id desc); | |
| 24 | +create index ix_re_memo_unresolved on re_memo (org_id) where not resolved; |
--- src/main/resources/mapper/DashboardMapper.xml
+++ src/main/resources/mapper/DashboardMapper.xml
... | ... | @@ -41,8 +41,7 @@ |
| 41 | 41 |
organization.updated_at만으로는 부족한 이유: 배정·단계·채널 변경 때만 갱신되고 |
| 42 | 42 |
권리확인 업로드나 업무메모 작성은 이 컬럼을 건드리지 않는다. |
| 43 | 43 |
|
| 44 |
- re_count는 RE 테이블이 아직 없어서 0으로 고정한다. RE 모델이 들어오면 여기에 |
|
| 45 |
- 서브쿼리 하나만 붙이면 화면은 그대로 동작한다. |
|
| 44 |
+ re_count는 아직 해결하지 않은 RE 메모 건수다(요구사항 [4]: 메모 1개 = RE 1개). |
|
| 46 | 45 |
--> |
| 47 | 46 |
<select id="findOrgRows" resultMap="orgRowResultMap"> |
| 48 | 47 |
select |
... | ... | @@ -61,8 +60,12 @@ |
| 61 | 60 |
(extract(epoch from sh.stage_at) * 1000)::bigint as stage_changed_at, |
| 62 | 61 |
(extract(epoch from greatest(o.updated_at, sh.any_at, rv.last_at, pr.last_at, wm.last_at)) * 1000)::bigint |
| 63 | 62 |
as last_changed_at, |
| 64 |
- 0 as re_count |
|
| 63 |
+ coalesce(re.unresolved, 0) as re_count |
|
| 65 | 64 |
from organization o |
| 65 |
+ left join ( |
|
| 66 |
+ select org_id, count(*) as unresolved |
|
| 67 |
+ from re_memo where not resolved group by org_id |
|
| 68 |
+ ) re on re.org_id = o.id |
|
| 66 | 69 |
left join contact lc on lc.id = o.lawyer_contact_id |
| 67 | 70 |
left join contact mc on mc.id = o.mj_contact_id |
| 68 | 71 |
left join ( |
--- src/main/resources/mapper/ProcessItemMapper.xml
+++ src/main/resources/mapper/ProcessItemMapper.xml
... | ... | @@ -173,6 +173,7 @@ |
| 173 | 173 |
process_status = coalesce(#{processStatus}, process_status),
|
| 174 | 174 |
judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
|
| 175 | 175 |
final_opinion = coalesce(#{finalOpinion}, final_opinion),
|
| 176 |
+ review_note = coalesce(#{reviewNote}, review_note),
|
|
| 176 | 177 |
processed_at = case when #{processStatus} = #{doneStatus} then now() else processed_at end,
|
| 177 | 178 |
web_edited_at = now(), |
| 178 | 179 |
updated_at = now() |
--- src/main/resources/mapper/ProjectMapper.xml
+++ src/main/resources/mapper/ProjectMapper.xml
... | ... | @@ -71,6 +71,22 @@ |
| 71 | 71 |
where id = #{id} and org_id = #{orgId}
|
| 72 | 72 |
</select> |
| 73 | 73 |
|
| 74 |
+ <!-- |
|
| 75 |
+ 보고서 교체(요구사항 [25]). 같은 자리에 새 파일을 덮어쓰고 올린 사람·시각을 갱신한다. |
|
| 76 |
+ 새 행을 만들지 않는 이유는 "고쳐 올리기"가 개정판 쌓기와 다른 동작이기 때문이다 - |
|
| 77 |
+ 개정판으로 남기고 싶으면 지금처럼 새로 올리면 된다. |
|
| 78 |
+ --> |
|
| 79 |
+ <update id="updateReport"> |
|
| 80 |
+ update org_report set |
|
| 81 |
+ file_name = #{fileName},
|
|
| 82 |
+ content_type = #{contentType},
|
|
| 83 |
+ byte_size = #{byteSize},
|
|
| 84 |
+ content = #{content},
|
|
| 85 |
+ uploaded_by = #{uploadedBy},
|
|
| 86 |
+ uploaded_at = now() |
|
| 87 |
+ where id = #{id} and org_id = #{orgId}
|
|
| 88 |
+ </update> |
|
| 89 |
+ |
|
| 74 | 90 |
<delete id="deleteReport"> |
| 75 | 91 |
delete from org_report where id = #{id} and org_id = #{orgId}
|
| 76 | 92 |
</delete> |
+++ src/main/resources/mapper/ReMemoMapper.xml
... | ... | @@ -0,0 +1,63 @@ |
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<mapper namespace="kr.itn.itnhub.re.ReMemoMapper"> | |
| 5 | + | |
| 6 | + <!-- | |
| 7 | + memo_date는 화면이 그대로 쓰도록 yyyy-MM-dd 문자열로, created_at은 다른 매퍼와 같은 | |
| 8 | + 관례로 밀리초 epoch로 내려준다. | |
| 9 | + --> | |
| 10 | + <sql id="columns"> | |
| 11 | + id, org_id, content, author, | |
| 12 | + to_char(memo_date, 'YYYY-MM-DD') as memo_date, | |
| 13 | + resolved, | |
| 14 | + (extract(epoch from created_at) * 1000)::bigint as created_at | |
| 15 | + </sql> | |
| 16 | + | |
| 17 | + <select id="findByOrg" resultType="kr.itn.itnhub.re.ReMemo"> | |
| 18 | + select <include refid="columns"/> | |
| 19 | + from re_memo | |
| 20 | + where org_id = #{orgId} | |
| 21 | + order by memo_date desc, id desc | |
| 22 | + </select> | |
| 23 | + | |
| 24 | + <select id="findById" resultType="kr.itn.itnhub.re.ReMemo"> | |
| 25 | + select <include refid="columns"/> | |
| 26 | + from re_memo | |
| 27 | + where org_id = #{orgId} and id = #{id} | |
| 28 | + </select> | |
| 29 | + | |
| 30 | + <!-- 일자를 비워 보내면 오늘로 넣는다. --> | |
| 31 | + <insert id="insert"> | |
| 32 | + insert into re_memo (org_id, content, author, memo_date, resolved, resolved_at) | |
| 33 | + values (#{orgId}, #{content}, #{author}, | |
| 34 | + coalesce(cast(#{memoDate} as date), current_date), | |
| 35 | + #{resolved}, | |
| 36 | + case when #{resolved} then now() else null end) | |
| 37 | + </insert> | |
| 38 | + | |
| 39 | + <!-- 해결로 바뀌는 순간만 resolved_at을 찍고, 되돌리면 지운다. --> | |
| 40 | + <update id="update"> | |
| 41 | + update re_memo set | |
| 42 | + content = #{content}, | |
| 43 | + author = #{author}, | |
| 44 | + memo_date = coalesce(cast(#{memoDate} as date), memo_date), | |
| 45 | + resolved = #{resolved}, | |
| 46 | + resolved_at = case | |
| 47 | + when #{resolved} and resolved_at is null then now() | |
| 48 | + when not #{resolved} then null | |
| 49 | + else resolved_at | |
| 50 | + end, | |
| 51 | + updated_at = now() | |
| 52 | + where org_id = #{orgId} and id = #{id} | |
| 53 | + </update> | |
| 54 | + | |
| 55 | + <delete id="delete"> | |
| 56 | + delete from re_memo where org_id = #{orgId} and id = #{id} | |
| 57 | + </delete> | |
| 58 | + | |
| 59 | + <delete id="deleteByOrg"> | |
| 60 | + delete from re_memo where org_id = #{orgId} | |
| 61 | + </delete> | |
| 62 | + | |
| 63 | +</mapper> |
--- src/main/resources/mapper/ReviewItemMapper.xml
+++ src/main/resources/mapper/ReviewItemMapper.xml
... | ... | @@ -153,6 +153,7 @@ |
| 153 | 153 |
review_result = coalesce(#{reviewResult}, review_result),
|
| 154 | 154 |
judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
|
| 155 | 155 |
opinion = coalesce(#{opinion}, opinion),
|
| 156 |
+ lawyer_note = coalesce(#{lawyerNote}, lawyer_note),
|
|
| 156 | 157 |
web_edited_at = now(), |
| 157 | 158 |
updated_at = now() |
| 158 | 159 |
where org_id = #{orgId}
|
+++ src/test/java/kr/itn/itnhub/re/ReMemoControllerTest.java
... | ... | @@ -0,0 +1,141 @@ |
| 1 | +package kr.itn.itnhub.re; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.mattermost.MattermostClient; | |
| 5 | +import kr.itn.itnhub.org.Organization; | |
| 6 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 11 | +import org.springframework.boot.test.mock.mockito.MockBean; | |
| 12 | +import org.springframework.http.MediaType; | |
| 13 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 14 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 15 | +import org.springframework.test.web.servlet.MockMvc; | |
| 16 | + | |
| 17 | +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; | |
| 18 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | |
| 19 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 20 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 21 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 22 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 23 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 24 | + | |
| 25 | +@AutoConfigureMockMvc | |
| 26 | +@WithMockUser(roles = "ADMIN") | |
| 27 | +class ReMemoControllerTest extends AbstractDbTest { | |
| 28 | + | |
| 29 | + @Autowired | |
| 30 | + MockMvc mvc; | |
| 31 | + | |
| 32 | + @Autowired | |
| 33 | + OrganizationMapper orgMapper; | |
| 34 | + | |
| 35 | + @Autowired | |
| 36 | + JdbcTemplate jdbc; | |
| 37 | + | |
| 38 | + @MockBean | |
| 39 | + MattermostClient mattermost; | |
| 40 | + | |
| 41 | + private Long orgId; | |
| 42 | + | |
| 43 | + @BeforeEach | |
| 44 | + void setUp() { | |
| 45 | + jdbc.update("delete from re_memo"); | |
| 46 | + jdbc.update("delete from organization"); | |
| 47 | + | |
| 48 | + Organization org = new Organization(); | |
| 49 | + org.setOrgNo("001"); | |
| 50 | + org.setOrgName("RE테스트기관"); | |
| 51 | + org.setChannelSlug("001"); | |
| 52 | + orgMapper.upsertBySeed(org); | |
| 53 | + orgId = orgMapper.findByOrgNoAndOrgName("001", "RE테스트기관").getId(); | |
| 54 | + } | |
| 55 | + | |
| 56 | + private String body(String content, String author, String date) { | |
| 57 | + return "{\"content\":\"" + content + "\",\"author\":\"" + author | |
| 58 | + + "\",\"memoDate\":\"" + date + "\",\"resolved\":false}"; | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + void 메모를_등록하면_목록에_최신순으로_쌓인다() throws Exception { | |
| 63 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 64 | + .contentType(MediaType.APPLICATION_JSON) | |
| 65 | + .content(body("ooo 계약서 및 제안요청서 송부 요청", "장영익", "2026-07-08"))) | |
| 66 | + .andExpect(status().isOk()); | |
| 67 | + | |
| 68 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 69 | + .contentType(MediaType.APPLICATION_JSON) | |
| 70 | + .content(body("추가 자료 요청", "장영익", "2026-07-10"))) | |
| 71 | + .andExpect(status().isOk()) | |
| 72 | + .andExpect(jsonPath("$.length()").value(2)) | |
| 73 | + // 일자 최신순 | |
| 74 | + .andExpect(jsonPath("$[0].content").value("추가 자료 요청")) | |
| 75 | + .andExpect(jsonPath("$[0].memoDate").value("2026-07-10")) | |
| 76 | + .andExpect(jsonPath("$[0].author").value("장영익")) | |
| 77 | + .andExpect(jsonPath("$[0].resolved").value(false)); | |
| 78 | + } | |
| 79 | + | |
| 80 | + @Test | |
| 81 | + void 내용과_작성자는_비울_수_없다() throws Exception { | |
| 82 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 83 | + .contentType(MediaType.APPLICATION_JSON) | |
| 84 | + .content(body("", "장영익", "2026-07-08"))) | |
| 85 | + .andExpect(status().isBadRequest()); | |
| 86 | + | |
| 87 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 88 | + .contentType(MediaType.APPLICATION_JSON) | |
| 89 | + .content(body("내용", "", "2026-07-08"))) | |
| 90 | + .andExpect(status().isBadRequest()); | |
| 91 | + } | |
| 92 | + | |
| 93 | + @Test | |
| 94 | + void 해결로_바꾸면_대시보드_미해결_수에서_빠진다() throws Exception { | |
| 95 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 96 | + .contentType(MediaType.APPLICATION_JSON) | |
| 97 | + .content(body("미해결 건", "장영익", "2026-07-08"))) | |
| 98 | + .andExpect(status().isOk()); | |
| 99 | + | |
| 100 | + mvc.perform(get("/api/dashboard")) | |
| 101 | + .andExpect(jsonPath("$.summary.unresolvedRe").value(1)); | |
| 102 | + | |
| 103 | + Long memoId = jdbc.queryForObject( | |
| 104 | + "select id from re_memo where org_id = ?", Long.class, orgId); | |
| 105 | + | |
| 106 | + mvc.perform(put("/api/orgs/{id}/re-memos/{memoId}", orgId, memoId).with(csrf()) | |
| 107 | + .contentType(MediaType.APPLICATION_JSON) | |
| 108 | + .content("{\"content\":\"미해결 건\",\"author\":\"장영익\"," | |
| 109 | + + "\"memoDate\":\"2026-07-08\",\"resolved\":true}")) | |
| 110 | + .andExpect(status().isOk()) | |
| 111 | + .andExpect(jsonPath("$[0].resolved").value(true)); | |
| 112 | + | |
| 113 | + mvc.perform(get("/api/dashboard")) | |
| 114 | + .andExpect(jsonPath("$.summary.unresolvedRe").value(0)); | |
| 115 | + } | |
| 116 | + | |
| 117 | + @Test | |
| 118 | + void 메모를_지울_수_있다() throws Exception { | |
| 119 | + mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf()) | |
| 120 | + .contentType(MediaType.APPLICATION_JSON) | |
| 121 | + .content(body("지울 건", "장영익", "2026-07-08"))) | |
| 122 | + .andExpect(status().isOk()); | |
| 123 | + | |
| 124 | + Long memoId = jdbc.queryForObject( | |
| 125 | + "select id from re_memo where org_id = ?", Long.class, orgId); | |
| 126 | + | |
| 127 | + mvc.perform(delete("/api/orgs/{id}/re-memos/{memoId}", orgId, memoId).with(csrf())) | |
| 128 | + .andExpect(status().isNoContent()); | |
| 129 | + | |
| 130 | + mvc.perform(get("/api/orgs/{id}/re-memos", orgId)) | |
| 131 | + .andExpect(jsonPath("$.length()").value(0)); | |
| 132 | + } | |
| 133 | + | |
| 134 | + @Test | |
| 135 | + void 없는_메모를_고치면_404다() throws Exception { | |
| 136 | + mvc.perform(put("/api/orgs/{id}/re-memos/{memoId}", orgId, 999999L).with(csrf()) | |
| 137 | + .contentType(MediaType.APPLICATION_JSON) | |
| 138 | + .content(body("없는 건", "장영익", "2026-07-08"))) | |
| 139 | + .andExpect(status().isNotFound()); | |
| 140 | + } | |
| 141 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?