kintex/src/frontend/src/screens/work/OpinionPage.tsx

285 lines
11 KiB
TypeScript

/*
* SCR-43 의견접수 (opinion). 참조: UIWS opinion/*.
* 좌: 접수 폼 + 내 접수 목록(상태 배지) / 우: 상세(답변 스레드).
* 백엔드: /api/work/opinions. 상태변경·답변=매니저(requireManager).
*/
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { opinionApi } from '../../api/endpoints';
import type { OpinionDto, OpinionSaveRequest } from '../../api/types';
import { useAuthStore } from '../../store/authStore';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { StatusPill, errMessage, fmtDateTime, useToast } from './workShared';
import './work.css';
const CATEGORIES = ['규정 문의', '개선 제안', '불편 신고', '기타'];
const STATUS_FLOW = ['접수', '검토중', '답변완료'];
export function OpinionPage() {
const qc = useQueryClient();
const { show, node: toast } = useToast();
const isManager = useAuthStore((s) => s.user?.hallManager ?? false);
const [statusFilter, setStatusFilter] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [form, setForm] = useState<OpinionSaveRequest>({
category: CATEGORIES[0],
title: '',
content: '',
secretYn: 'N',
});
const [comment, setComment] = useState('');
const listQ = useQuery({
queryKey: ['opinions', statusFilter],
queryFn: () => opinionApi.list({ status: statusFilter || undefined, page: 0, size: 50 }),
});
const detailQ = useQuery({
queryKey: ['opinion', selectedId],
queryFn: () => opinionApi.get(selectedId as string),
enabled: !!selectedId,
});
const createM = useMutation({
mutationFn: (b: OpinionSaveRequest) => opinionApi.create(b),
onSuccess: () => {
show('의견이 접수되었습니다.');
setForm({ category: CATEGORIES[0], title: '', content: '', secretYn: 'N' });
qc.invalidateQueries({ queryKey: ['opinions'] });
},
onError: (e) => show(errMessage(e)),
});
const statusM = useMutation({
mutationFn: ({ id, value }: { id: string; value: string }) => opinionApi.changeStatus(id, value),
onSuccess: () => {
show('상태가 변경되었습니다.');
qc.invalidateQueries({ queryKey: ['opinions'] });
qc.invalidateQueries({ queryKey: ['opinion', selectedId] });
},
onError: (e) => show(errMessage(e)),
});
const commentM = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => opinionApi.comment(id, content),
onSuccess: () => {
show('답변이 등록되었습니다.');
setComment('');
qc.invalidateQueries({ queryKey: ['opinion', selectedId] });
},
onError: (e) => show(errMessage(e)),
});
const rows = listQ.data?.items ?? [];
return (
<div className="kx-page">
<header className="kx-work__head">
<div>
<h1 className="kx-work__title"></h1>
<p className="kx-work__subtitle"> · · </p>
</div>
</header>
<div className="kx-split">
{/* 좌: 접수 폼 + 목록 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<section className="kx-card" aria-label="의견 접수">
<div className="kx-card__head">
<h2> </h2>
</div>
<div className="kx-formgrid">
<label className="kx-field">
<span className="kx-label"></span>
<select
className="kx-select"
value={form.category ?? ''}
onChange={(e) => setForm({ ...form, category: e.target.value })}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label className="kx-switch" style={{ alignSelf: 'end' }}>
<input
type="checkbox"
checked={form.secretYn === 'Y'}
onChange={(e) => setForm({ ...form, secretYn: e.target.checked ? 'Y' : 'N' })}
/>
<span>(·)</span>
</label>
<label className="kx-field kx-field--full">
<span className="kx-label"> *</span>
<input
className="kx-input"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
/>
</label>
<label className="kx-field kx-field--full">
<span className="kx-label"></span>
<textarea
className="kx-textarea"
value={form.content ?? ''}
onChange={(e) => setForm({ ...form, content: e.target.value })}
/>
</label>
</div>
<div className="kx-detail__actions" style={{ marginTop: 12 }}>
<Button disabled={!form.title.trim() || createM.isPending} onClick={() => createM.mutate(form)}>
{createM.isPending ? '제출 중…' : '제출'}
</Button>
</div>
</section>
<section className="kx-card" aria-label="접수 목록">
<div className="kx-card__head">
<h2> </h2>
<select
className="kx-select"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value=""> </option>
{STATUS_FLOW.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div className="kx-table-scroll">
<table className="kx-list-table">
<thead>
<tr>
<th></th>
<th style={{ width: 96 }}></th>
<th style={{ width: 96 }}></th>
<th style={{ width: 120 }}></th>
</tr>
</thead>
<tbody>
{listQ.isLoading &&
Array.from({ length: 4 }).map((_, i) => (
<tr key={i}>
<td colSpan={4}>
<Skeleton height={20} />
</td>
</tr>
))}
{!listQ.isLoading &&
rows.map((r: OpinionDto) => (
<tr
key={r.id}
className={selectedId === r.id ? 'is-selected' : ''}
onClick={() => setSelectedId(r.id)}
>
<td>
<span className="kx-list-table__title">{r.title}</span>
{r.secretYn === 'Y' && (
<span className="kx-list-table__muted"> · </span>
)}
</td>
<td className="kx-list-table__muted">{r.category ?? '-'}</td>
<td>
<StatusPill value={r.status} />
</td>
<td className="kx-list-table__muted kx-num">{fmtDateTime(r.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
{listQ.isError && (
<ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} />
)}
{!listQ.isLoading && !listQ.isError && rows.length === 0 && (
<EmptyState title="접수된 의견이 없습니다" />
)}
</section>
</div>
{/* 우: 상세 */}
<aside className="kx-detail" aria-label="의견 상세">
{!selectedId ? (
<EmptyState title="의견을 선택하세요" description="목록에서 항목을 선택하면 상세·답변이 표시됩니다." />
) : detailQ.isLoading ? (
<>
<Skeleton height={24} width="70%" />
<Skeleton height={80} />
</>
) : detailQ.isError ? (
<ErrorState message={errMessage(detailQ.error)} onRetry={() => detailQ.refetch()} />
) : detailQ.data ? (
<>
<div className="kx-detail__head">
<h2 className="kx-detail__title">{detailQ.data.opinion.title}</h2>
<StatusPill value={detailQ.data.opinion.status} />
</div>
<div className="kx-detail__meta">
<span>{detailQ.data.opinion.authorName}</span>
<span>· {detailQ.data.opinion.category}</span>
<span>· {fmtDateTime(detailQ.data.opinion.createdAt)}</span>
</div>
<div className="kx-detail__body">{detailQ.data.opinion.content || '(내용 없음)'}</div>
{isManager && (
<div className="kx-field">
<span className="kx-label"> ()</span>
<div className="kx-noti__chips">
{STATUS_FLOW.map((s) => (
<button
key={s}
className={`kx-chip ${detailQ.data!.opinion.status === s ? 'is-active' : ''}`}
onClick={() => statusM.mutate({ id: detailQ.data!.opinion.id, value: s })}
>
{s}
</button>
))}
</div>
</div>
)}
<div className="kx-thread">
{detailQ.data.comments.length === 0 && (
<p className="kx-list-table__muted"> .</p>
)}
{detailQ.data.comments.map((c) => (
<div className="kx-thread__item" key={c.id}>
<div className="kx-thread__meta">
<span className="kx-thread__author">{c.authorName}</span>
<span>{fmtDateTime(c.createdAt)}</span>
</div>
<div className="kx-thread__text">{c.content}</div>
</div>
))}
{isManager && (
<div className="kx-thread__compose">
<textarea
className="kx-textarea"
placeholder="답변 작성…"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
<Button
disabled={!comment.trim() || commentM.isPending}
onClick={() =>
commentM.mutate({ id: detailQ.data!.opinion.id, content: comment })
}
>
</Button>
</div>
)}
</div>
</>
) : null}
</aside>
</div>
{toast}
</div>
);
}