feat: 확인 완료 버튼 추가

This commit is contained in:
root
2026-09-04 16:40:31 +09:00
parent 090e9fdf25
commit 4a79555d16
14 changed files with 2114 additions and 694 deletions
@@ -1018,26 +1018,6 @@ const FeedbackDetailSheet = (props: Props) => {
)}
</p>
</div>
<div>
<p className="text-neutral-secondary text-xs"> IP</p>
<p className="mt-1 font-medium">
{String(
currentFeedback.IP ??
ticketExtraFields.ip_address ??
'-',
)}
</p>
</div>
<div>
<p className="text-neutral-secondary text-xs">MAC </p>
<p className="mt-1 font-medium">
{String(
currentFeedback.MAC_address ??
ticketExtraFields.mac_address ??
'-',
)}
</p>
</div>
</div>
</div>
@@ -0,0 +1,46 @@
export const SUPPORT_FEEDBACK_STATUS_CODES = [
'NEW',
'RECEIVED',
'IN_PROGRESS',
'COMPLETED',
'ON_HOLD',
] as const;
export type SupportFeedbackStatusCode =
(typeof SUPPORT_FEEDBACK_STATUS_CODES)[number];
const LEGACY_STATUS_TO_CURRENT: Record<string, SupportFeedbackStatusCode> = {
INIT: 'NEW',
ON_REVIEW: 'RECEIVED',
DETAILED_REVIEW: 'RECEIVED',
IN_PROGRESS: 'IN_PROGRESS',
RESOLVED: 'COMPLETED',
PENDING: 'ON_HOLD',
};
const STATUS_LABELS: Record<SupportFeedbackStatusCode, string> = {
NEW: '신규',
RECEIVED: '접수',
IN_PROGRESS: '진행중',
COMPLETED: '완료',
ON_HOLD: '보류',
};
export const normalizeSupportFeedbackStatus = (
value?: string | null,
): SupportFeedbackStatusCode | null => {
if (!value) return null;
if (
(SUPPORT_FEEDBACK_STATUS_CODES as readonly string[]).includes(value)
) {
return value as SupportFeedbackStatusCode;
}
return LEGACY_STATUS_TO_CURRENT[value] ?? null;
};
export const getSupportFeedbackStatusLabel = (value?: string | null) => {
const normalized = normalizeSupportFeedbackStatus(value);
return normalized ? STATUS_LABELS[normalized] : value || '상태 미상';
};
@@ -13,8 +13,16 @@
* License for the specific language governing permissions and limitations
* under the License.
*/
import {
getSupportFeedbackStatusLabel,
normalizeSupportFeedbackStatus,
} from '../lib/support-feedback-status';
const statusMap: Record<string, string> = {
NEW: 'bg-[#f2d5d5] text-[#8c3030]',
RECEIVED: 'bg-[#f3ead0] text-[#7d5b16]',
COMPLETED: 'bg-[#dcede3] text-[#2f6f52]',
ON_HOLD: 'bg-[#f7dfd3] text-[#8b4228]',
PENDING_APPROVAL: 'bg-[#f7dfd3] text-[#8b4228]',
APPROVED: 'bg-[#d6e9d8] text-[#1e5d39]',
IN_PROGRESS: 'bg-[#d9e7f5] text-[#204f79]',
@@ -31,15 +39,27 @@ const statusMap: Record<string, string> = {
interface Props {
label: string;
value: string;
normalizeFeedbackStatus?: boolean;
}
const SupportStatusBadge = ({ label, value }: Props) => {
const SupportStatusBadge = ({
label,
value,
normalizeFeedbackStatus = false,
}: Props) => {
const normalizedValue = normalizeFeedbackStatus
? normalizeSupportFeedbackStatus(value) ?? value
: value;
const displayValue = normalizeFeedbackStatus
? getSupportFeedbackStatusLabel(value)
: value;
return (
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusMap[value] ?? 'bg-[#ece8e1] text-[#5f5649]'}`}>
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusMap[normalizedValue] ?? 'bg-[#ece8e1] text-[#5f5649]'}`}>
<span className="uppercase tracking-[0.16em] opacity-70">{label}</span>
<span>{value}</span>
<span>{displayValue}</span>
</span>
);
};
export default SupportStatusBadge;
export default SupportStatusBadge;
@@ -0,0 +1,56 @@
import { createNextApiHandler } from '@/server/api-handler';
import { getSupportAuthHeaders } from '@/server/support-auth';
import { supportExternalUrl } from '@/server/support-external';
const readJsonOrMessage = async (response: Response) => {
const raw = await response.text();
if (!raw.trim()) return {};
try {
return JSON.parse(raw) as unknown;
} catch {
return { message: raw.trim().slice(0, 1000) };
}
};
const handler = createNextApiHandler({
POST: async (req, res) => {
const ticketId = Number(req.query.ticketId);
const workspaceCode =
typeof req.query.workspaceCode === 'string' ?
req.query.workspaceCode.trim()
: '';
if (!Number.isInteger(ticketId) || ticketId <= 0) {
return res.status(400).json({ message: 'ticketId가 필요합니다.' });
}
if (!workspaceCode) {
return res.status(400).json({ message: 'workspaceCode가 필요합니다.' });
}
try {
const query = new URLSearchParams({ workspaceCode });
const response = await fetch(
`${supportExternalUrl(`/tickets/${ticketId}/completion-confirmation`)}?${query.toString()}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getSupportAuthHeaders(req),
},
body: JSON.stringify({}),
signal: AbortSignal.timeout(30000),
},
);
return res.status(response.status).json(await readJsonOrMessage(response));
} catch {
return res.status(502).json({
message: '관리 콘솔 API에서 완료 확인을 처리하지 못했습니다.',
});
}
},
});
export default handler;
@@ -28,9 +28,14 @@ import {
import type { NextPageWithLayout } from '@/shared/types';
import { formatSupportTimestamp } from '@/features/support-portal/lib/format-support-timestamp';
import { getSupportCategoryLabel } from '@/features/support-portal/lib/support-category';
import { normalizeSupportFeedbackStatus } from '@/features/support-portal/lib/support-feedback-status';
import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui';
import SupportStatusBadge from '@/features/support-portal/ui/support-status-badge.ui';
import type { SupportTicketRecord } from '@/server/support-types';
import type {
SupportCompletionConfirmationResponse,
SupportTicketRecord,
} from '@/server/support-types';
interface SupportTicketCommentRecord {
comment_id: number;
@@ -40,6 +45,8 @@ interface SupportTicketCommentRecord {
author_name: string;
content: string;
is_internal: boolean;
comment_type?: string;
completion_notice?: boolean;
created_at: string;
updated_at: string;
edited_at: string | null;
@@ -69,6 +76,12 @@ const formatAttachmentSize = (fileSize?: number | null) => {
return `${Math.max(1, Math.round(fileSize / 1024))} KB`;
};
const isCompletionNoticeComment = (
comment: Pick<SupportTicketCommentRecord, 'comment_type' | 'completion_notice'>,
) =>
comment.completion_notice === true ||
comment.comment_type === 'COMPLETION_NOTICE';
const issueStatusLabelMap: Record<string, string> = {
INIT: '신규',
ON_REVIEW: '검토중',
@@ -110,11 +123,9 @@ const SupportDetailPage: NextPageWithLayout = () => {
const [isEditing, setIsEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState('');
const [draftDescription, setDraftDescription] = useState('');
const [draftExtraFields, setDraftExtraFields] = useState<
Record<string, string>
>({});
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isCompletionConfirming, setIsCompletionConfirming] = useState(false);
const [comments, setComments] = useState<SupportTicketCommentRecord[]>([]);
const [commentDraft, setCommentDraft] = useState('');
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
@@ -186,7 +197,6 @@ const SupportDetailPage: NextPageWithLayout = () => {
setTicket(data as SupportTicketRecord);
setDraftTitle((data as SupportTicketRecord).title);
setDraftDescription((data as SupportTicketRecord).description);
setDraftExtraFields((data as SupportTicketRecord).extra_fields);
await fetchComments(
ticketId,
(data as SupportTicketRecord).extra_fields?.abc_feedback_id,
@@ -416,11 +426,7 @@ const SupportDetailPage: NextPageWithLayout = () => {
const nextTitle = draftTitle.trim();
const nextDescription = draftDescription.trim();
const nextExtraFields = {
...(ticket.extra_fields),
ip_address: draftExtraFields.ip_address?.trim() ?? '',
mac_address: draftExtraFields.mac_address?.trim() ?? '',
};
const nextExtraFields = { ...ticket.extra_fields };
if (!nextTitle || !nextDescription) {
toast.error('제목과 내용을 입력해 주세요.');
@@ -462,7 +468,6 @@ const SupportDetailPage: NextPageWithLayout = () => {
setTicket(updatedTicket);
setDraftTitle(updatedTicket.title);
setDraftDescription(updatedTicket.description);
setDraftExtraFields(updatedTicket.extra_fields);
setIsEditing(false);
toast.success('문의가 수정되었습니다.');
} catch (error) {
@@ -513,6 +518,78 @@ const SupportDetailPage: NextPageWithLayout = () => {
}
};
const handleCompletionConfirmation = async () => {
if (!ticket || isCompletionConfirming) return;
const confirmed = window.confirm(
'처리 결과를 확인했으며 이 문의를 완료 상태로 변경하시겠습니까?',
);
if (!confirmed) return;
setIsCompletionConfirming(true);
setErrorMessage('');
try {
const response = await fetchWithAuthRefresh(
`/api/support/tickets/${ticket.ticket_id}/completion-confirmation?${new URLSearchParams({
workspaceCode,
}).toString()}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
},
);
const data = (await response.json()) as
| SupportCompletionConfirmationResponse
| { message?: string; detail?: string };
if (!response.ok) {
throw new Error(
('message' in data && data.message) ||
('detail' in data && data.detail) ||
'완료 확인을 처리하지 못했습니다.',
);
}
const completion = data as SupportCompletionConfirmationResponse;
setTicket((current) =>
current ?
{
...current,
feedback_status: completion.feedback_status,
completion_requested: false,
completion_confirmed_at: completion.completed_at,
}
: current,
);
toast.success('처리 결과 확인이 완료되었습니다.');
} catch (error) {
const message =
error instanceof Error ?
error.message
: '완료 확인 처리 중 오류가 발생했습니다.';
setErrorMessage(message);
toast.error(message);
} finally {
setIsCompletionConfirming(false);
}
};
const normalizedFeedbackStatus = normalizeSupportFeedbackStatus(
ticket?.feedback_status,
);
const hasCompletionNoticeComment = comments.some(isCompletionNoticeComment);
const completionRequested =
typeof ticket?.completion_requested === 'boolean' ?
ticket.completion_requested
: hasCompletionNoticeComment;
const isCompletionConfirmationAvailable =
completionRequested &&
normalizedFeedbackStatus === 'IN_PROGRESS';
return (
<SupportPortalShell
workspaceCode={workspaceCode || 'Q&A_Platform'}
@@ -549,6 +626,11 @@ const SupportDetailPage: NextPageWithLayout = () => {
🔒
</span>
: null}
<SupportStatusBadge
label="상태"
value={ticket.feedback_status}
normalizeFeedbackStatus
/>
{isEditing ?
<input
value={draftTitle}
@@ -592,53 +674,6 @@ const SupportDetailPage: NextPageWithLayout = () => {
</div>
</div>
<div className="mt-5 rounded-[8px] border border-[#ececec] bg-[#fafafa] p-4">
<p className="text-sm font-semibold text-[#222]">
</p>
<div className="mt-3 grid gap-3 text-sm text-[#555] sm:grid-cols-2">
<label className="flex flex-col gap-1">
<span className="font-medium text-[#333]">
IP
</span>
{isEditing ?
<input
value={draftExtraFields.ip_address ?? ''}
onChange={(event) =>
setDraftExtraFields((current) => ({
...current,
ip_address: event.target.value,
}))
}
placeholder="예: 192.168.0.10"
className="h-[40px] rounded-[4px] border border-[#d8d8d8] bg-white px-3 text-sm outline-none focus:border-[#888]"
/>
: <span>
{ticket.extra_fields.ip_address?.trim() ?? '-'}
</span>
}
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-[#333]">MAC </span>
{isEditing ?
<input
value={draftExtraFields.mac_address ?? ''}
onChange={(event) =>
setDraftExtraFields((current) => ({
...current,
mac_address: event.target.value,
}))
}
placeholder="예: 00:1A:2B:3C:4D:5E"
className="h-[40px] rounded-[4px] border border-[#d8d8d8] bg-white px-3 text-sm outline-none focus:border-[#888]"
/>
: <span>
{ticket.extra_fields.mac_address?.trim() ?? '-'}
</span>
}
</label>
</div>
</div>
<div className="mt-6">
{isEditing ?
<textarea
@@ -653,6 +688,47 @@ const SupportDetailPage: NextPageWithLayout = () => {
}
</div>
{(isCompletionConfirmationAvailable ||
ticket.completion_confirmed_at) && (
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-[8px] border border-[#cddff5] bg-[#f3f8ff] p-4">
<div>
<p className="text-sm font-semibold text-[#234d7e]">
{isCompletionConfirmationAvailable ?
'처리 결과 확인 대기'
: '처리 결과 확인 완료'}
</p>
{isCompletionConfirmationAvailable &&
ticket.completion_requested_at && (
<p className="mt-1 text-xs text-[#55708f]">
:{' '}
{formatSupportTimestamp(
ticket.completion_requested_at,
)}
</p>
)}
{!isCompletionConfirmationAvailable &&
ticket.completion_confirmed_at && (
<p className="mt-1 text-xs text-[#55708f]">
:{' '}
{formatSupportTimestamp(
ticket.completion_confirmed_at,
)}
</p>
)}
</div>
{isCompletionConfirmationAvailable && (
<button
type="button"
onClick={() => void handleCompletionConfirmation()}
disabled={isCompletionConfirming}
className="rounded-[4px] bg-[#315f9a] px-5 py-3 text-sm font-semibold text-white transition hover:bg-[#264c7d] disabled:cursor-not-allowed disabled:bg-[#91a9c5]"
>
{isCompletionConfirming ? '확인 처리중...' : '처리 결과 확인'}
</button>
)}
</div>
)}
<div className="mt-8 border-t border-[#ececec] pt-5">
<p className="text-sm font-semibold text-[#222]"></p>
{ticket.attachments && ticket.attachments.length > 0 ?
@@ -690,7 +766,6 @@ const SupportDetailPage: NextPageWithLayout = () => {
onClick={() => {
setDraftTitle(ticket.title);
setDraftDescription(ticket.description);
setDraftExtraFields(ticket.extra_fields);
setIsEditing(false);
}}
className="rounded-[4px] border border-[#d0d0d0] bg-white px-5 py-3 text-sm font-medium text-[#333] transition hover:bg-[#f7f7f7]"
@@ -761,9 +836,16 @@ const SupportDetailPage: NextPageWithLayout = () => {
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-[#222]">
{comment.author_name}
</p>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-[#222]">
{comment.author_name}
</p>
{isCompletionNoticeComment(comment) && (
<span className="rounded-full bg-[#e8f1ff] px-2 py-1 text-[11px] font-medium text-[#315f9a]">
</span>
)}
</div>
<p className="mt-1 text-xs text-[#777]">
{formatSupportTimestamp(comment.created_at)}
{comment.edited_at ? ' · 수정됨' : ''}
@@ -201,7 +201,7 @@ const SupportListPage: NextPageWithLayout = () => {
case 'title':
return ticket.title;
case 'status':
return ticket.status_code;
return ticket.feedback_status;
case 'created_at':
default:
return Date.parse(ticket.created_at) || 0;
@@ -430,7 +430,8 @@ const SupportListPage: NextPageWithLayout = () => {
<td className="px-4 py-4 align-top">
<SupportStatusBadge
label="status"
value={ticket.status_code}
value={ticket.feedback_status}
normalizeFeedbackStatus
/>
</td>
</tr>
@@ -22,7 +22,6 @@ import { toast } from '@ufb/react';
import {
DEFAULT_SUPPORT_WORKSPACE_CODE,
DescriptionTooltip,
fetchWithAuthRefresh,
} from '@/shared';
import type { NextPageWithLayout } from '@/shared/types';
@@ -75,14 +74,6 @@ const minimalFieldCopy: Record<string, { label: string; placeholder: string }> =
label: '내용',
placeholder: '문의 내용을 자세히 입력해 주세요.',
},
ip_address: {
label: '사용자 IP 주소',
placeholder: '예: 192.168.0.10',
},
mac_address: {
label: 'MAC 주소',
placeholder: '예: 00:1A:2B:3C:4D:5E',
},
};
const getFieldCopy = (fieldCode: string, fallbackLabel: string) => {
@@ -98,8 +89,6 @@ const allowedFieldCodes = [
'title',
'description',
'category',
'ip_address',
'mac_address',
];
const demoCompatibilityFields: WorkspaceFormField[] = [
@@ -126,18 +115,6 @@ const demoCompatibilityFields: WorkspaceFormField[] = [
field_type: 'textarea',
required: true,
},
{
field_code: 'ip_address',
label: '사용자 IP 주소',
field_type: 'text',
required: false,
},
{
field_code: 'mac_address',
label: 'MAC 주소',
field_type: 'text',
required: false,
},
];
const normalizeDemoTemplate = (
@@ -163,13 +140,6 @@ const normalizeDemoTemplate = (
};
};
const fieldHelp: Record<string, string> = {
ip_address:
'Windows: 명령 프롬프트에서 ipconfig를 실행한 뒤 IPv4 주소를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > TCP/IP에서 확인하세요.',
mac_address:
'Windows: 명령 프롬프트에서 ipconfig /all을 실행한 뒤 Physical Address를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > 하드웨어에서 확인하세요.',
};
const SupportNewPage: NextPageWithLayout = () => {
const router = useRouter();
const workspaceCode =
@@ -342,7 +312,12 @@ const SupportNewPage: NextPageWithLayout = () => {
'requires_approval',
String(template?.requires_approval ?? false),
);
formData.append('extra_fields', JSON.stringify(fieldValues));
const submittedFieldValues = Object.fromEntries(
Object.entries(fieldValues).filter(([fieldCode]) =>
allowedFieldCodes.includes(fieldCode),
),
);
formData.append('extra_fields', JSON.stringify(submittedFieldValues));
for (const attachment of attachments) {
formData.append('attachments', attachment.file, attachment.file.name);
@@ -531,14 +506,6 @@ const SupportNewPage: NextPageWithLayout = () => {
placeholder={copy.placeholder}
className="h-[40px] min-w-0 flex-1 rounded-[4px] border border-[#d8d8d8] px-3 text-[14px] text-[#222] outline-none placeholder:text-[#a0a0a0] focus:border-[#888]"
/>
{fieldHelp[field.field_code] ?
<DescriptionTooltip
description={
fieldHelp[field.field_code] ?? ''
}
side="right"
/>
: null}
</div>
}
</td>
+17 -1
View File
@@ -45,6 +45,22 @@ export interface WorkspaceFormTemplateResponse {
fields: WorkspaceFormField[];
}
export interface SupportFeedbackAutomationMetadata {
completion_requested?: boolean;
completion_requested_at?: string | null;
completion_notice_comment_id?: number | null;
completion_expires_at?: string | null;
completion_confirmed_at?: string | null;
completion_confirmed_by?: string | null;
is_new?: boolean;
}
export interface SupportCompletionConfirmationResponse {
feedback_id: number;
feedback_status: string;
completed_at: string;
}
export interface SupportActivityItem {
id: string;
label: string;
@@ -61,7 +77,7 @@ export interface SupportAttachmentRecord {
download_url?: string;
}
export interface SupportTicketRecord {
export interface SupportTicketRecord extends SupportFeedbackAutomationMetadata {
ticket_id: number;
workspace_code: string;
workspace_name: string;