feat: 기관관리 담당자 카드에 생성/변경 버튼과 선택 모달 연결
@8b2d35fffc5bf16704842c089368d6ad14989af8
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
... | ... | @@ -148,7 +148,7 @@ |
| 148 | 148 |
page === 'channels' ? ( |
| 149 | 149 |
<OrgDetail org={selected} onChanged={() => void reload()} />
|
| 150 | 150 |
) : ( |
| 151 |
- <OrgOverview org={selected} />
|
|
| 151 |
+ <OrgOverview org={selected} onChanged={() => void reload()} />
|
|
| 152 | 152 |
) |
| 153 | 153 |
) : ( |
| 154 | 154 |
<p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p> |
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
... | ... | @@ -7,6 +7,8 @@ |
| 7 | 7 |
getPosts: vi.fn(), |
| 8 | 8 |
getFiles: vi.fn(), |
| 9 | 9 |
getMemos: vi.fn(), |
| 10 |
+ getContacts: vi.fn(), |
|
| 11 |
+ updateAssignments: vi.fn(), |
|
| 10 | 12 |
})) |
| 11 | 13 |
|
| 12 | 14 |
vi.mock('../api/client', async (importOriginal) => ({
|
... | ... | @@ -14,6 +16,8 @@ |
| 14 | 16 |
getPosts: mocks.getPosts, |
| 15 | 17 |
getFiles: mocks.getFiles, |
| 16 | 18 |
getMemos: mocks.getMemos, |
| 19 |
+ getContacts: mocks.getContacts, |
|
| 20 |
+ updateAssignments: mocks.updateAssignments, |
|
| 17 | 21 |
})) |
| 18 | 22 |
|
| 19 | 23 |
function contact(overrides: Partial<Contact> = {}): Contact {
|
... | ... | @@ -59,6 +63,8 @@ |
| 59 | 63 |
mocks.getPosts.mockReset().mockResolvedValue([]) |
| 60 | 64 |
mocks.getFiles.mockReset().mockResolvedValue([]) |
| 61 | 65 |
mocks.getMemos.mockReset().mockResolvedValue([]) |
| 66 |
+ mocks.getContacts.mockReset().mockResolvedValue([]) |
|
| 67 |
+ mocks.updateAssignments.mockReset().mockResolvedValue(undefined) |
|
| 62 | 68 |
}) |
| 63 | 69 |
|
| 64 | 70 |
describe('OrgOverview', () => {
|
... | ... | @@ -128,6 +134,47 @@ |
| 128 | 134 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 129 | 135 |
}) |
| 130 | 136 |
|
| 137 |
+ it('배정된 카드에는 변경, 빈 카드에는 생성 버튼이 보인다', async () => {
|
|
| 138 |
+ render(<OrgOverview org={org()} />)
|
|
| 139 |
+ |
|
| 140 |
+ expect(screen.getByRole('button', { name: '신청기관 담당자 변경' })).toBeTruthy()
|
|
| 141 |
+ expect(screen.getByRole('button', { name: '문정원 담당자 생성' })).toBeTruthy()
|
|
| 142 |
+ expect(screen.getByRole('button', { name: '담당 변호사 생성' })).toBeTruthy()
|
|
| 143 |
+ await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
|
| 144 |
+ }) |
|
| 145 |
+ |
|
| 146 |
+ it('생성 버튼을 누르면 선택 모달이 열리고 담당자를 고르면 배정 API를 부른다', async () => {
|
|
| 147 |
+ mocks.getContacts.mockResolvedValue([ |
|
| 148 |
+ contact({ id: 7, category: 'MJ', name: '이연경', deptName: '공공저작물팀' }),
|
|
| 149 |
+ ]) |
|
| 150 |
+ const onChanged = vi.fn() |
|
| 151 |
+ |
|
| 152 |
+ render(<OrgOverview org={org()} onChanged={onChanged} />)
|
|
| 153 |
+ |
|
| 154 |
+ fireEvent.click(screen.getByRole('button', { name: '문정원 담당자 생성' }))
|
|
| 155 |
+ |
|
| 156 |
+ await waitFor(() => expect(screen.getByText('문정원 담당자 선택')).toBeTruthy())
|
|
| 157 |
+ fireEvent.click(await screen.findByRole('button', { name: '선택' }))
|
|
| 158 |
+ |
|
| 159 |
+ await waitFor(() => |
|
| 160 |
+ expect(mocks.updateAssignments).toHaveBeenCalledWith(1, {
|
|
| 161 |
+ applicantContactId: 1, |
|
| 162 |
+ mjContactId: 7, |
|
| 163 |
+ lawyerContactId: null, |
|
| 164 |
+ lawyerAssignedDate: null, |
|
| 165 |
+ }), |
|
| 166 |
+ ) |
|
| 167 |
+ expect(onChanged).toHaveBeenCalled() |
|
| 168 |
+ }) |
|
| 169 |
+ |
|
| 170 |
+ it('상단 동작 버튼에서 담당 변호사 변경은 사라졌다', async () => {
|
|
| 171 |
+ render(<OrgOverview org={org()} />)
|
|
| 172 |
+ |
|
| 173 |
+ expect(screen.queryByRole('button', { name: '담당 변호사 변경' })).toBeNull()
|
|
| 174 |
+ expect(screen.getByRole('button', { name: '진행단계 변경' })).toBeDisabled()
|
|
| 175 |
+ await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
|
| 176 |
+ }) |
|
| 177 |
+ |
|
| 131 | 178 |
it('업무단계 12개가 순서대로 보인다', async () => {
|
| 132 | 179 |
render(<OrgOverview org={org()} />)
|
| 133 | 180 |
|
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
... | ... | @@ -1,7 +1,13 @@ |
| 1 | 1 |
import { useState } from 'react'
|
| 2 |
-import type { Org } from '../api/client'
|
|
| 2 |
+import {
|
|
| 3 |
+ updateAssignments, |
|
| 4 |
+ type Contact, |
|
| 5 |
+ type ContactCategory, |
|
| 6 |
+ type Org, |
|
| 7 |
+} from '../api/client' |
|
| 3 | 8 |
import ChannelFiles from './ChannelFiles' |
| 4 | 9 |
import ChannelPosts from './ChannelPosts' |
| 10 |
+import ContactPickerModal from './ContactPickerModal' |
|
| 5 | 11 |
import StatusBadge from './StatusBadge' |
| 6 | 12 |
import WorkMemos from './WorkMemos' |
| 7 | 13 |
|
... | ... | @@ -27,7 +33,18 @@ |
| 27 | 33 |
|
| 28 | 34 |
const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모'] |
| 29 | 35 |
|
| 30 |
-const DISABLED_ACTIONS = ['진행단계 변경', '담당 변호사 변경', '자료 업로드', '최초 게시글 보기', '보고서 관리'] |
|
| 36 |
+// 담당 변호사 변경은 카드의 [생성/변경] 버튼으로 대체되어 목록에서 뺐다 |
|
| 37 |
+const DISABLED_ACTIONS = ['진행단계 변경', '자료 업로드', '최초 게시글 보기', '보고서 관리'] |
|
| 38 |
+ |
|
| 39 |
+// 카드에서 담당자를 지정/변경할 때 쓰는 역할 정의 (채널관리의 배정과 같은 API를 쓴다) |
|
| 40 |
+const CARD_ROLES: Record< |
|
| 41 |
+ 'applicant' | 'mj' | 'lawyer', |
|
| 42 |
+ { category: ContactCategory; pickerTitle: string }
|
|
| 43 |
+> = {
|
|
| 44 |
+ applicant: { category: 'APPLICANT', pickerTitle: '신청기관 담당자 선택' },
|
|
| 45 |
+ mj: { category: 'MJ', pickerTitle: '문정원 담당자 선택' },
|
|
| 46 |
+ lawyer: { category: 'LAWYER', pickerTitle: '담당 변호사 선택' },
|
|
| 47 |
+} |
|
| 31 | 48 |
|
| 32 | 49 |
// Mattermost 웹 주소. 채널 화면은 팀명 + 채널 내부명으로 열린다. |
| 33 | 50 |
// 내부명 규칙은 백엔드 ChannelNaming.java가 정본이며 여기서는 열람 링크용으로만 복제한다. |
... | ... | @@ -53,13 +70,30 @@ |
| 53 | 70 |
function ContactCard({
|
| 54 | 71 |
title, |
| 55 | 72 |
fields, |
| 73 |
+ assigned, |
|
| 74 |
+ busy, |
|
| 75 |
+ onEdit, |
|
| 56 | 76 |
}: {
|
| 57 | 77 |
title: string |
| 58 | 78 |
fields: { label: string; value: string | null }[]
|
| 79 |
+ assigned: boolean |
|
| 80 |
+ busy: boolean |
|
| 81 |
+ onEdit: () => void |
|
| 59 | 82 |
}) {
|
| 60 | 83 |
return ( |
| 61 | 84 |
<section className="rounded-lg border border-gray-200 p-4"> |
| 62 |
- <h2 className="text-sm font-semibold">{title}</h2>
|
|
| 85 |
+ <div className="flex items-center justify-between"> |
|
| 86 |
+ <h2 className="text-sm font-semibold">{title}</h2>
|
|
| 87 |
+ <button |
|
| 88 |
+ type="button" |
|
| 89 |
+ disabled={busy}
|
|
| 90 |
+ aria-label={`${title} ${assigned ? '변경' : '생성'}`}
|
|
| 91 |
+ onClick={onEdit}
|
|
| 92 |
+ className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40" |
|
| 93 |
+ > |
|
| 94 |
+ {assigned ? '변경' : '생성'}
|
|
| 95 |
+ </button> |
|
| 96 |
+ </div> |
|
| 63 | 97 |
<div className="mt-3 space-y-1.5"> |
| 64 | 98 |
{fields.map((f) => (
|
| 65 | 99 |
<Field key={f.label} label={f.label} value={f.value} />
|
... | ... | @@ -69,8 +103,36 @@ |
| 69 | 103 |
) |
| 70 | 104 |
} |
| 71 | 105 |
|
| 72 |
-export default function OrgOverview({ org }: { org: Org }) {
|
|
| 106 |
+export default function OrgOverview({
|
|
| 107 |
+ org, |
|
| 108 |
+ onChanged, |
|
| 109 |
+}: {
|
|
| 110 |
+ org: Org |
|
| 111 |
+ onChanged?: () => void |
|
| 112 |
+}) {
|
|
| 73 | 113 |
const [activeTab, setActiveTab] = useState(TABS[0]) |
| 114 |
+ const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'lawyer' | null>(null) |
|
| 115 |
+ const [assignBusy, setAssignBusy] = useState(false) |
|
| 116 |
+ const [assignError, setAssignError] = useState<string | null>(null) |
|
| 117 |
+ |
|
| 118 |
+ async function assign(role: 'applicant' | 'mj' | 'lawyer', contact: Contact) {
|
|
| 119 |
+ setPickerRole(null) |
|
| 120 |
+ setAssignError(null) |
|
| 121 |
+ setAssignBusy(true) |
|
| 122 |
+ try {
|
|
| 123 |
+ await updateAssignments(org.id, {
|
|
| 124 |
+ applicantContactId: role === 'applicant' ? contact.id : (org.applicant?.id ?? null), |
|
| 125 |
+ mjContactId: role === 'mj' ? contact.id : (org.mj?.id ?? null), |
|
| 126 |
+ lawyerContactId: role === 'lawyer' ? contact.id : (org.lawyer?.id ?? null), |
|
| 127 |
+ lawyerAssignedDate: org.lawyerAssignedDate ?? null, |
|
| 128 |
+ }) |
|
| 129 |
+ onChanged?.() |
|
| 130 |
+ } catch (e) {
|
|
| 131 |
+ setAssignError(e instanceof Error ? e.message : '담당자 지정에 실패했습니다.') |
|
| 132 |
+ } finally {
|
|
| 133 |
+ setAssignBusy(false) |
|
| 134 |
+ } |
|
| 135 |
+ } |
|
| 74 | 136 |
|
| 75 | 137 |
return ( |
| 76 | 138 |
<div className="p-6"> |
... | ... | @@ -118,11 +180,14 @@ |
| 118 | 180 |
</div> |
| 119 | 181 |
</div> |
| 120 | 182 |
|
| 121 |
- {/* 담당자 카드 3개 (신청기관/문정원/변호사) - 담당자 정보는 담당자관리에서만 입력하고,
|
|
| 122 |
- 여기서는 org.applicant/mj/lawyer로 배정된 담당자를 읽기 전용으로 보여준다. */} |
|
| 183 |
+ {/* 담당자 카드 3개 (신청기관/문정원/변호사). 담당자 정보 입력은 담당자관리에서만 하고,
|
|
| 184 |
+ 여기서는 [생성/변경]으로 이미 등록된 담당자를 골라 배정만 한다(채널관리와 같은 API). */} |
|
| 123 | 185 |
<div className="mt-5 grid grid-cols-1 gap-3 lg:grid-cols-3"> |
| 124 | 186 |
<ContactCard |
| 125 | 187 |
title="신청기관 담당자" |
| 188 |
+ assigned={org.applicant !== null}
|
|
| 189 |
+ busy={assignBusy}
|
|
| 190 |
+ onEdit={() => setPickerRole('applicant')}
|
|
| 126 | 191 |
fields={[
|
| 127 | 192 |
{ label: '부서', value: org.applicant?.deptName ?? null },
|
| 128 | 193 |
{
|
... | ... | @@ -139,6 +204,9 @@ |
| 139 | 204 |
/> |
| 140 | 205 |
<ContactCard |
| 141 | 206 |
title="문정원 담당자" |
| 207 |
+ assigned={org.mj !== null}
|
|
| 208 |
+ busy={assignBusy}
|
|
| 209 |
+ onEdit={() => setPickerRole('mj')}
|
|
| 142 | 210 |
fields={[
|
| 143 | 211 |
{ label: '부서', value: org.mj?.deptName ?? null },
|
| 144 | 212 |
{ label: '담당자', value: org.mj?.name ?? null },
|
... | ... | @@ -148,6 +216,9 @@ |
| 148 | 216 |
/> |
| 149 | 217 |
<ContactCard |
| 150 | 218 |
title="담당 변호사" |
| 219 |
+ assigned={org.lawyer !== null}
|
|
| 220 |
+ busy={assignBusy}
|
|
| 221 |
+ onEdit={() => setPickerRole('lawyer')}
|
|
| 151 | 222 |
fields={[
|
| 152 | 223 |
{ label: '성명', value: org.lawyer?.name ?? null },
|
| 153 | 224 |
{ label: '연락처', value: org.lawyer?.phone ?? null },
|
... | ... | @@ -157,6 +228,17 @@ |
| 157 | 228 |
/> |
| 158 | 229 |
</div> |
| 159 | 230 |
|
| 231 |
+ {assignError && <p className="mt-2 text-sm text-red-600">{assignError}</p>}
|
|
| 232 |
+ |
|
| 233 |
+ {pickerRole && (
|
|
| 234 |
+ <ContactPickerModal |
|
| 235 |
+ category={CARD_ROLES[pickerRole].category}
|
|
| 236 |
+ title={CARD_ROLES[pickerRole].pickerTitle}
|
|
| 237 |
+ onSelect={(contact) => void assign(pickerRole, contact)}
|
|
| 238 |
+ onClose={() => setPickerRole(null)}
|
|
| 239 |
+ /> |
|
| 240 |
+ )} |
|
| 241 |
+ |
|
| 160 | 242 |
{/* 업무단계 현황 */}
|
| 161 | 243 |
<section className="mt-5"> |
| 162 | 244 |
<h2 className="text-sm font-semibold">업무단계 현황</h2> |
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?