This commit is contained in:
commit
33453ecc55
3475 files changed
+850363
No files matched your search
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { NextPage } from 'next';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { Button } from '@ufb/react';
|
||||
|
||||
import { Path } from '@/shared';
|
||||
|
||||
const UnauthorizedPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
<Image
|
||||
src="/assets/images/403.svg"
|
||||
alt="403"
|
||||
width={320}
|
||||
height={320}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h3 className="text-title-h3">Access restricted</h3>
|
||||
<p className="text-neutral-tertiary">
|
||||
You don’t have permission to access. please contact to owner and ask
|
||||
to you invite.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push(Path.MAIN)}>Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnauthorizedPage;
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { Button } from '@ufb/react';
|
||||
|
||||
import { Path } from '@/shared';
|
||||
|
||||
const NotFoundPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
<Image
|
||||
src="/assets/images/404.svg"
|
||||
alt="404"
|
||||
width={320}
|
||||
height={320}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h3 className="text-title-h3">Page not Found</h3>
|
||||
<p className="text-neutral-tertiary">
|
||||
The page you are looking for isn’t here.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push(Path.MAIN)}>Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundPage;
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AppProps } from 'next/app';
|
||||
import { Inter } from 'next/font/google';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { DehydratedState } from '@tanstack/react-query';
|
||||
import {
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query';
|
||||
import { OverlayProvider } from '@toss/use-overlay';
|
||||
import { appWithTranslation } from 'next-i18next';
|
||||
import i18nConfig from 'next-i18next.config';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/pages';
|
||||
|
||||
import { Toaster, TooltipProvider } from '@ufb/react';
|
||||
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { TenantGuard } from '@/entities/tenant';
|
||||
import { useUserStore } from '@/entities/user';
|
||||
|
||||
// NOTE: DON'T Change the following import order
|
||||
import '@/shared/styles/global.css';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/free-mode';
|
||||
import 'swiper/css/navigation';
|
||||
import 'swiper/css/thumbs';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
interface PageProps {
|
||||
dehydratedState?: DehydratedState;
|
||||
}
|
||||
|
||||
type AppPropsWithLayout = AppProps<PageProps> & {
|
||||
Component: NextPageWithLayout;
|
||||
};
|
||||
|
||||
function App({ Component, pageProps }: AppPropsWithLayout) {
|
||||
const { dehydratedState, ...otherProps } = pageProps;
|
||||
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
const { setUser, user, randomId } = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) return;
|
||||
void setUser();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const broadcastChannel = new BroadcastChannel('ufb');
|
||||
const fn = (event: MessageEvent<{ type: string; payload: number }>) => {
|
||||
if (event.data.type === 'reload' && event.data.payload !== randomId) {
|
||||
router.reload();
|
||||
}
|
||||
};
|
||||
broadcastChannel.addEventListener('message', fn);
|
||||
return () => {
|
||||
broadcastChannel.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>ABC User Feedback</title>
|
||||
<link rel="shortcut icon" href="/assets/images/logo.svg" />
|
||||
</Head>
|
||||
<style jsx global>{`
|
||||
html {
|
||||
font-family: ${inter.style.fontFamily};
|
||||
}
|
||||
`}</style>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system">
|
||||
<OverlayProvider>
|
||||
<NuqsAdapter>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<TenantGuard>
|
||||
{getLayout(<Component {...otherProps} />)}
|
||||
<Toaster />
|
||||
</TenantGuard>
|
||||
</TooltipProvider>
|
||||
</HydrationBoundary>
|
||||
</NuqsAdapter>
|
||||
</OverlayProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default appWithTranslation(App, i18nConfig);
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import Document, { Head, Html, Main, NextScript } from 'next/document';
|
||||
|
||||
class MyDocument extends Document {
|
||||
render() {
|
||||
return (
|
||||
<Html suppressHydrationWarning>
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MyDocument;
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import { formatSupportTimestamp } from '@/features/support-portal/lib/format-support-timestamp';
|
||||
import SupportOperationsShell from '@/features/support-portal/ui/support-operations-shell.ui';
|
||||
import SupportStatusBadge from '@/features/support-portal/ui/support-status-badge.ui';
|
||||
import { SUPPORT_TEST_ADMIN_USER_EMAIL, SUPPORT_TEST_TENANT_ID } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import type { SupportTicketRecord } from '@/server/support-stub';
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
import { AnonymousLayout } from '@/widgets/anonymous-layout';
|
||||
|
||||
interface SupportTicketCommentRecord {
|
||||
comment_id: number;
|
||||
ticket_id: number;
|
||||
author_id: string;
|
||||
author_tenant_id: string;
|
||||
author_name: string;
|
||||
content: string;
|
||||
is_internal: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
edited_at: string | null;
|
||||
}
|
||||
|
||||
const AdminIssuesPage: NextPageWithLayout = () => {
|
||||
const [tickets, setTickets] = useState<SupportTicketRecord[]>([]);
|
||||
const [ticketComments, setTicketComments] = useState<Record<number, SupportTicketCommentRecord[]>>({});
|
||||
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [workingId, setWorkingId] = useState<number | null>(null);
|
||||
|
||||
const loadTickets = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/support/tickets');
|
||||
const data = (await response.json()) as SupportTicketRecord[] | { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('message' in data && data.message ? data.message : '이슈 대상을 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
const nextTickets = data as SupportTicketRecord[];
|
||||
setTickets(nextTickets);
|
||||
|
||||
const commentEntries = await Promise.all(
|
||||
nextTickets.map(async (ticket) => {
|
||||
try {
|
||||
const commentsResponse = await fetch(`/api/support/tickets/${ticket.ticket_id}/comments`);
|
||||
const commentsData = (await commentsResponse.json()) as SupportTicketCommentRecord[] | { message?: string };
|
||||
|
||||
if (!commentsResponse.ok) {
|
||||
return [ticket.ticket_id, []] as const;
|
||||
}
|
||||
|
||||
return [ticket.ticket_id, commentsData as SupportTicketCommentRecord[]] as const;
|
||||
} catch {
|
||||
return [ticket.ticket_id, []] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setTicketComments((Object.fromEntries(commentEntries) as Record<number, SupportTicketCommentRecord[]>));
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '이슈 대상 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadTickets();
|
||||
}, []);
|
||||
|
||||
const issueCandidates = useMemo(
|
||||
() =>
|
||||
tickets.filter(
|
||||
(ticket) =>
|
||||
ticket.approval_status === 'APPROVED' || ticket.issue_link_status === 'LINKED',
|
||||
),
|
||||
[tickets],
|
||||
);
|
||||
|
||||
const handleCreateIssue = async (ticketId: number) => {
|
||||
setWorkingId(ticketId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/support/tickets/${ticketId}/issue`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = (await response.json()) as { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message ?? '이슈 연결에 실패했습니다.');
|
||||
}
|
||||
|
||||
await loadTickets();
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '이슈 연결 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setWorkingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateComment = async (ticketId: number) => {
|
||||
const nextContent = (commentDrafts[ticketId] ?? '').trim();
|
||||
|
||||
if (!nextContent) {
|
||||
toast.error('관리자 댓글 내용을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingId(ticketId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/support/tickets/${ticketId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requester_id: SUPPORT_TEST_ADMIN_USER_EMAIL,
|
||||
requester_tenant_id: SUPPORT_TEST_TENANT_ID,
|
||||
author_name: 'Admin User',
|
||||
content: nextContent,
|
||||
is_internal: true,
|
||||
}),
|
||||
});
|
||||
const data = (await response.json()) as SupportTicketCommentRecord | { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('message' in data && data.message ? data.message : '관리자 댓글 저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
setTicketComments((current) => ({
|
||||
...current,
|
||||
[ticketId]: [...(current[ticketId] ?? []), data as SupportTicketCommentRecord],
|
||||
}));
|
||||
setCommentDrafts((current) => ({
|
||||
...current,
|
||||
[ticketId]: '',
|
||||
}));
|
||||
toast.success('관리자 댓글이 저장되었습니다.');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '관리자 댓글 저장 중 오류가 발생했습니다.';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setWorkingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SupportOperationsShell
|
||||
currentTab="admin"
|
||||
eyebrow="Admin Console"
|
||||
title="승인 완료 요청을 ABC 이슈로 연결하는 관리자 화면"
|
||||
description={[
|
||||
'운영 승인 이후 항목만 활성화해서 이슈 생성 책임이 어디서 시작되는지 분리했습니다.',
|
||||
'feedback_status는 고객 피드백 처리 상태로, issue_link_status와 분리되어 관리됩니다.',
|
||||
].join('\n')}
|
||||
aside={
|
||||
<div className="rounded-[24px] bg-[#143125] p-4 text-[#edf4e9]">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-[#afc5b0]">Issue status</p>
|
||||
<p className="mt-2 text-sm leading-6">
|
||||
candidates={issueCandidates.length}
|
||||
<br />
|
||||
linked={issueCandidates.filter((ticket) => ticket.issue_link_status === 'LINKED').length}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<section className="rounded-[32px] border border-white/80 bg-white/82 p-7 shadow-[0_18px_50px_rgba(43,63,50,0.08)]">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-[#6f8374]">Issue handoff</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.02em] text-[#1d3324]">이슈 생성 대상</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadTickets()}
|
||||
className="rounded-full border border-[#ccd8ca] bg-white px-4 py-2 text-sm font-medium text-[#284133] transition hover:bg-[#f7faf5]"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="mt-6 text-sm text-[#506055]">이슈 대상을 불러오는 중입니다...</p>}
|
||||
|
||||
{!isLoading && errorMessage && (
|
||||
<div className="mt-6 rounded-[24px] border border-[#f2c7c7] bg-[#fff4f2] p-4 text-sm text-[#8d4343]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !errorMessage && issueCandidates.length === 0 && (
|
||||
<div className="mt-6 rounded-[24px] bg-[#f3f5ef] p-8 text-sm text-[#526556]">
|
||||
승인 완료된 요청이 아직 없어 이슈 생성 대상이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{issueCandidates.map((ticket) => {
|
||||
const canCreateIssue =
|
||||
ticket.approval_status === 'APPROVED' && ticket.issue_link_status !== 'LINKED';
|
||||
const comments = ticketComments[ticket.ticket_id] ?? [];
|
||||
const commentDraft = commentDrafts[ticket.ticket_id] ?? '';
|
||||
const isWorking = workingId === ticket.ticket_id;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={ticket.ticket_id}
|
||||
className="rounded-[28px] border border-[#dde7dc] bg-[linear-gradient(135deg,_rgba(255,255,255,0.96),_rgba(245,250,244,0.92))] p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#6f8374]">
|
||||
<span>{ticket.workspace_code}</span>
|
||||
<span>•</span>
|
||||
<span>Ticket {ticket.ticket_id}</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold tracking-[-0.02em] text-[#1f3526]">{ticket.title}</h3>
|
||||
<p className="text-sm leading-7 text-[#546759]">{ticket.description}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<SupportStatusBadge label="approval" value={ticket.approval_status} />
|
||||
<SupportStatusBadge label="issue" value={ticket.issue_link_status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
<SupportStatusBadge label="feedback" value={ticket.feedback_status} />
|
||||
<SupportStatusBadge label="workflow" value={ticket.status_code} />
|
||||
{ticket.external_issue_status && (
|
||||
<SupportStatusBadge label="external issue" value={ticket.external_issue_status} />
|
||||
)}
|
||||
<SupportStatusBadge label="sync" value={ticket.sync_status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-[24px] bg-[#eef4eb] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[#6b816e]">Latest activity</p>
|
||||
<p className="mt-2 text-sm leading-7 text-[#425647]">
|
||||
{ticket.activity[0]?.detail ?? '활동 이력이 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-[24px] bg-[#f8faf6] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[#6b816e]">Comments</p>
|
||||
<span className="text-xs text-[#758877]">{comments.length}건</span>
|
||||
</div>
|
||||
|
||||
{comments.length === 0 ? (
|
||||
<p className="mt-2 text-sm leading-7 text-[#647665]">등록된 댓글이 없습니다.</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{comments.slice(-3).reverse().map((comment) => (
|
||||
<div key={comment.comment_id} className="rounded-[18px] border border-[#dde7dc] bg-white px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-[#2e4332]">{comment.author_name}</p>
|
||||
<span className={`rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${comment.is_internal ? 'bg-[#143125] text-[#edf4e9]' : 'bg-[#edf4e9] text-[#38533e]'}`}>
|
||||
{comment.is_internal ? 'internal' : 'user'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#839483]">{formatSupportTimestamp(comment.created_at)}</span>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-[#506055]">{comment.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-[18px] border border-[#d7e2d5] bg-white p-3">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[#6b816e]">Admin internal note</p>
|
||||
<textarea
|
||||
value={commentDraft}
|
||||
onChange={(event) =>
|
||||
setCommentDrafts((current) => ({
|
||||
...current,
|
||||
[ticket.ticket_id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
placeholder="관리자 메모나 처리 상황을 남겨 주세요."
|
||||
className="mt-3 w-full resize-none rounded-[14px] border border-[#d7e2d5] px-3 py-2 text-sm text-[#233426] outline-none transition focus:border-[#6b816e]"
|
||||
/>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateComment(ticket.ticket_id)}
|
||||
disabled={isWorking}
|
||||
className="rounded-full bg-[#284133] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#355542] disabled:cursor-not-allowed disabled:bg-[#9aaea1]"
|
||||
>
|
||||
{isWorking ? '저장 중...' : '관리자 댓글 저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canCreateIssue || isWorking}
|
||||
onClick={() => void handleCreateIssue(ticket.ticket_id)}
|
||||
className="mt-5 rounded-full bg-[#173629] px-5 py-2 text-sm font-medium text-white transition hover:bg-[#214937] disabled:cursor-not-allowed disabled:bg-[#9aaea1]"
|
||||
>
|
||||
{ticket.issue_link_status === 'LINKED'
|
||||
? `이슈 연결 완료 ABC-${ticket.ticket_id}`
|
||||
: isWorking
|
||||
? '이슈 연결 중...'
|
||||
: '이슈 생성'}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</SupportOperationsShell>
|
||||
);
|
||||
};
|
||||
|
||||
AdminIssuesPage.getLayout = (page: React.ReactNode) => {
|
||||
return <AnonymousLayout>{page}</AnonymousLayout>;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default AdminIssuesPage;
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(req.query)) {
|
||||
if (typeof value === 'string') {
|
||||
query.set(key, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
query.set(key, value[0] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
|
||||
return res.redirect(302, `/auth/oauth-callback${suffix}`);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: (_, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
},
|
||||
});
|
||||
export default handler;
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import { getSupportAuthHeaders } from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
supportApiBaseUrl +
|
||||
(req.query.candidates ? '/api/access/candidates' : req.query.management ? '/api/access/admins' : '/api/access/me'),
|
||||
{ headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res.status(503).json({
|
||||
message: '권한 정보를 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
},
|
||||
POST: async (req, res) => {
|
||||
const workspaceCode = req.query.workspaceCode as string;
|
||||
const isManagementRequest = Boolean(req.query.management);
|
||||
try {
|
||||
const target =
|
||||
isManagementRequest ?
|
||||
supportApiBaseUrl + '/api/access/admins'
|
||||
: supportApiBaseUrl +
|
||||
'/api/access/workspaces/' +
|
||||
workspaceCode +
|
||||
'/users';
|
||||
const response = await fetch(target, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getSupportAuthHeaders(req),
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
});
|
||||
const data = (await response.json()) as unknown;
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res.status(503).json({
|
||||
message: '권한 매핑을 저장할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
},
|
||||
DELETE: async (req, res) => {
|
||||
const assignmentId = req.query.assignmentId as string;
|
||||
try {
|
||||
const response = await fetch(
|
||||
supportApiBaseUrl + '/api/access/admins/' + assignmentId,
|
||||
{ headers: getSupportAuthHeaders(req), method: 'DELETE' },
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res.status(503).json({
|
||||
message: '권한 매핑을 삭제할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
findSupportWorkspaceCodeByAbcTarget,
|
||||
getAbcSupportMetrics,
|
||||
} from '@/server/support-abc';
|
||||
import { getSupportAuthHeaders, getSupportPrincipal } from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
const projectId = Number(req.query.projectId);
|
||||
const channelId = Number(req.query.channelId);
|
||||
|
||||
if (!Number.isInteger(projectId) || projectId <= 0) {
|
||||
return res.status(400).json({ message: 'projectId가 필요합니다.' });
|
||||
}
|
||||
|
||||
if (Number.isInteger(channelId) && channelId > 0) {
|
||||
try {
|
||||
const workspaceCode = findSupportWorkspaceCodeByAbcTarget(projectId, channelId);
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({ message: '로그인 세션을 확인할 수 없습니다.' });
|
||||
}
|
||||
if (!(principal.is_admin === true || principal.is_system_admin === true)) {
|
||||
return res.status(403).json({ message: '피드백 지표를 조회할 권한이 없습니다.' });
|
||||
}
|
||||
return res.status(200).json(await getAbcSupportMetrics(workspaceCode));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('No support workspace mapping')) {
|
||||
// Non-ABC channels continue to use the Secretary metrics endpoint.
|
||||
} else {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 피드백 지표를 불러오지 못했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({ projectId: String(projectId) });
|
||||
if (Number.isInteger(channelId) && channelId > 0) {
|
||||
query.set('channelId', String(channelId));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/metrics?${query.toString()}`,
|
||||
{ headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res.status(503).json({
|
||||
message: '피드백 운영 지표를 불러오지 못했습니다.',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
findSupportWorkspaceCodeByAbcTarget,
|
||||
listAbcSupportFeedbacks,
|
||||
} from '@/server/support-abc';
|
||||
import { getSupportPrincipal } from '@/server/support-auth';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
const projectId = Number(req.query.projectId);
|
||||
const channelId = Number(req.query.channelId);
|
||||
if (!Number.isInteger(projectId) || projectId <= 0) {
|
||||
return res.status(400).json({ message: 'projectId가 필요합니다.' });
|
||||
}
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({ message: '로그인 세션을 확인할 수 없습니다.' });
|
||||
}
|
||||
if (principal.is_admin !== true && principal.is_system_admin !== true) {
|
||||
return res.status(403).json({ message: '관리자만 피드백 상태를 조회할 수 있습니다.' });
|
||||
}
|
||||
|
||||
if (!Number.isInteger(channelId) || channelId <= 0) {
|
||||
return res.status(400).json({ message: 'channelId가 필요합니다.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const workspaceCode = findSupportWorkspaceCodeByAbcTarget(
|
||||
projectId,
|
||||
channelId,
|
||||
);
|
||||
const feedbacks = await listAbcSupportFeedbacks(workspaceCode);
|
||||
return res.status(200).json(
|
||||
feedbacks.map((feedback) => ({
|
||||
feedback_id: Number(feedback.id),
|
||||
feedback_status:
|
||||
typeof feedback.feedback_status === 'string' ?
|
||||
feedback.feedback_status
|
||||
: 'INIT',
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message:
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: 'ABC 피드백 상태를 불러오지 못했습니다.',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
deleteAbcSupportFeedback,
|
||||
findAbcSupportFeedback,
|
||||
getAbcFeedbackContent,
|
||||
getSupportAbcTargetConfig,
|
||||
updateAbcSupportFeedback,
|
||||
} from '@/server/support-abc';
|
||||
import {
|
||||
getSupportAuthHeaders,
|
||||
getSupportPrincipal,
|
||||
isSupportAdmin,
|
||||
} from '@/server/support-auth';
|
||||
import {
|
||||
deleteSupportTicketStub,
|
||||
getSupportTicketDetail,
|
||||
getSupportWorkspaceTemplate,
|
||||
updateSupportTicketStub,
|
||||
} from '@/server/support-stub';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const getFeedbackString = (feedback: Record<string, unknown>, key: string) =>
|
||||
typeof feedback[key] === 'string' ? feedback[key] : '';
|
||||
|
||||
const getFeedbackFlag = (feedback: Record<string, unknown>, key: string) => {
|
||||
const value = feedback[key];
|
||||
return (
|
||||
value === true ||
|
||||
(typeof value === 'string' && ['true', '1'].includes(value.toLowerCase()))
|
||||
);
|
||||
};
|
||||
|
||||
const isFeedbackSecret = (feedback: Record<string, unknown>) =>
|
||||
getFeedbackFlag(feedback, 'is_secret');
|
||||
|
||||
const getLegacyRequester = (feedback: Record<string, unknown>) => {
|
||||
const requester = feedback.requester;
|
||||
return requester && typeof requester === 'object' ?
|
||||
(requester as Record<string, unknown>)
|
||||
: null;
|
||||
};
|
||||
|
||||
const getAbcFeedbackAttachments = (feedback: Record<string, unknown>) => {
|
||||
const values = feedback.images;
|
||||
if (!Array.isArray(values)) return [];
|
||||
|
||||
const createdAt =
|
||||
typeof feedback.createdAt === 'string' ?
|
||||
feedback.createdAt
|
||||
: new Date().toISOString();
|
||||
|
||||
return values.flatMap((value, index) => {
|
||||
if (typeof value !== 'string' || !value.trim()) return [];
|
||||
|
||||
let fileName = `attachment-${index + 1}`;
|
||||
try {
|
||||
const lastSegment = new URL(value).pathname.split('/').pop();
|
||||
if (lastSegment) fileName = decodeURIComponent(lastSegment);
|
||||
} catch {
|
||||
// Keep the fallback name for legacy values that are not valid URLs.
|
||||
}
|
||||
|
||||
return [{
|
||||
attachment_id: -(index + 1),
|
||||
original_file_name: fileName,
|
||||
mime_type: null,
|
||||
file_size: null,
|
||||
created_at: createdAt,
|
||||
download_url: value,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const canManageSupportFeedback = isSupportAdmin;
|
||||
|
||||
const isFeedbackOwnedBy = (
|
||||
feedback: Record<string, unknown>,
|
||||
principal: { user_id: string; tenant_id: string; email?: string | null },
|
||||
) => {
|
||||
const requesterId = getFeedbackString(feedback, 'requester_id');
|
||||
const requesterTenantId = getFeedbackString(feedback, 'requester_tenant_id');
|
||||
|
||||
if (
|
||||
requesterId &&
|
||||
requesterTenantId &&
|
||||
requesterId === principal.user_id &&
|
||||
requesterTenantId === principal.tenant_id
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const legacyRequester = getLegacyRequester(feedback);
|
||||
return (
|
||||
!requesterId &&
|
||||
!requesterTenantId &&
|
||||
typeof legacyRequester?.login_id === 'string' &&
|
||||
typeof principal.email === 'string' &&
|
||||
legacyRequester.login_id === principal.email
|
||||
);
|
||||
};
|
||||
const applyCurrentAbcIssueState = async <T extends Record<string, unknown>>(
|
||||
workspaceCode: string,
|
||||
ticket: T,
|
||||
fallbackAbcFeedbackId?: number,
|
||||
) => {
|
||||
const extraFields =
|
||||
typeof ticket.extra_fields === 'object' && ticket.extra_fields !== null ?
|
||||
(ticket.extra_fields as Record<string, unknown>)
|
||||
: {};
|
||||
const abcFeedbackId = Number(
|
||||
extraFields.abc_feedback_id ?? fallbackAbcFeedbackId ?? ticket.ticket_id,
|
||||
);
|
||||
|
||||
if (!workspaceCode || !Number.isFinite(abcFeedbackId)) {
|
||||
return ticket;
|
||||
}
|
||||
|
||||
const feedback = await findAbcSupportFeedback(workspaceCode, abcFeedbackId);
|
||||
const issues = Array.isArray(feedback.issues) ? feedback.issues : [];
|
||||
const currentIssue = issues.find(
|
||||
(issue): issue is Record<string, unknown> =>
|
||||
typeof issue === 'object' && issue !== null,
|
||||
);
|
||||
const currentIssueStatus =
|
||||
currentIssue && typeof currentIssue.status === 'string' ?
|
||||
currentIssue.status
|
||||
: null;
|
||||
const { title, description } = getAbcFeedbackContent(feedback);
|
||||
|
||||
return {
|
||||
...ticket,
|
||||
title: title || ticket.title,
|
||||
description: description || ticket.description,
|
||||
external_issue_status: currentIssueStatus,
|
||||
issue_link_status: issues.length > 0 ? 'LINKED' : 'NOT_LINKED',
|
||||
};
|
||||
};
|
||||
|
||||
const synthesizeSupportTicketFromAbc = async (
|
||||
workspaceCode: string,
|
||||
ticketId: number,
|
||||
) => {
|
||||
const feedback = await findAbcSupportFeedback(workspaceCode, ticketId);
|
||||
const template = getSupportWorkspaceTemplate(workspaceCode);
|
||||
const title = typeof feedback.title === 'string' ? feedback.title : '';
|
||||
const description =
|
||||
typeof feedback.contents === 'string' ? feedback.contents
|
||||
: typeof feedback.message === 'string' ? feedback.message
|
||||
: '';
|
||||
const issues = Array.isArray(feedback.issues) ? feedback.issues : [];
|
||||
const currentIssue = issues.find(
|
||||
(issue): issue is Record<string, unknown> =>
|
||||
typeof issue === 'object' && issue !== null,
|
||||
);
|
||||
const currentIssueStatus =
|
||||
currentIssue && typeof currentIssue.status === 'string' ?
|
||||
currentIssue.status
|
||||
: null;
|
||||
|
||||
return {
|
||||
ticket_id: ticketId,
|
||||
workspace_code: workspaceCode,
|
||||
workspace_name: template.workspace_name,
|
||||
title,
|
||||
description,
|
||||
requester_id: getFeedbackString(feedback, 'requester_id'),
|
||||
requester_tenant_id: getFeedbackString(feedback, 'requester_tenant_id'),
|
||||
requester_contact: '',
|
||||
requester_email: getFeedbackString(feedback, 'requester_email'),
|
||||
requester_name: getFeedbackString(feedback, 'requester_name'),
|
||||
requester_department: getFeedbackString(feedback, 'requester_department'),
|
||||
requester_phone_number:
|
||||
getFeedbackString(feedback, 'requester_phone_number') ||
|
||||
getFeedbackString(feedback, 'phone_number'),
|
||||
category_code: 'GENERAL',
|
||||
ticket_type: template.requires_approval ? 'APPROVAL' : 'GENERAL',
|
||||
requires_approval: template.requires_approval,
|
||||
status_code: 'RECEIVED',
|
||||
feedback_status: 'INIT',
|
||||
approval_status: template.requires_approval ? 'PENDING' : 'NOT_REQUIRED',
|
||||
sync_status: 'SYNCED',
|
||||
issue_link_status: issues.length > 0 ? 'LINKED' : 'NOT_LINKED',
|
||||
external_issue_status: currentIssueStatus,
|
||||
created_at:
|
||||
typeof feedback.createdAt === 'string' ?
|
||||
feedback.createdAt
|
||||
: new Date().toISOString(),
|
||||
updated_at:
|
||||
typeof feedback.updatedAt === 'string' ?
|
||||
feedback.updatedAt
|
||||
: new Date().toISOString(),
|
||||
extra_fields: {
|
||||
abc_feedback_id: String(ticketId),
|
||||
},
|
||||
attachments: getAbcFeedbackAttachments(feedback),
|
||||
activity: [],
|
||||
};
|
||||
};
|
||||
|
||||
const synthesizeAbcTicketFromFeedback = async (
|
||||
workspaceCode: string,
|
||||
ticketId: number,
|
||||
) => {
|
||||
const feedback = await findAbcSupportFeedback(workspaceCode, ticketId);
|
||||
const issues = Array.isArray(feedback.issues) ? feedback.issues : [];
|
||||
const currentIssue = issues.find(
|
||||
(issue): issue is Record<string, unknown> =>
|
||||
typeof issue === 'object' && issue !== null,
|
||||
);
|
||||
const currentIssueStatus =
|
||||
currentIssue && typeof currentIssue.status === 'string' ?
|
||||
currentIssue.status
|
||||
: null;
|
||||
const { title, description } = getAbcFeedbackContent(feedback);
|
||||
const legacyRequester = getLegacyRequester(feedback);
|
||||
const getString = (key: string) => {
|
||||
const aliases =
|
||||
key === 'ip_address' ? ['ip_address', 'IP']
|
||||
: key === 'mac_address' ? ['mac_address', 'MAC_address', 'MAC']
|
||||
: key === 'category_code' ? ['category_code', 'category', 'Category']
|
||||
: [key];
|
||||
const value = aliases
|
||||
.map((alias) => feedback[alias])
|
||||
.find((candidate) => typeof candidate === 'string');
|
||||
if (typeof value === 'string') return value;
|
||||
|
||||
const legacyValues: Record<string, unknown> = {
|
||||
requester_id: legacyRequester?.login_id,
|
||||
requester_email: legacyRequester?.email ?? legacyRequester?.login_id,
|
||||
requester_name: legacyRequester?.name,
|
||||
requester_department: legacyRequester?.department,
|
||||
requester_phone_number:
|
||||
legacyRequester?.phone ?? legacyRequester?.phone_number,
|
||||
requester_contact: legacyRequester?.phone,
|
||||
};
|
||||
const legacyValue = legacyValues[key];
|
||||
return typeof legacyValue === 'string' ? legacyValue : '';
|
||||
};
|
||||
const feedbackStatus =
|
||||
getString('feedback_status') || getString('status') || 'INIT';
|
||||
const extraFields: Record<string, string> = {
|
||||
abc_feedback_id: String(ticketId),
|
||||
};
|
||||
|
||||
for (const key of [
|
||||
'ip_address',
|
||||
'mac_address',
|
||||
'priority',
|
||||
'requester_email',
|
||||
'requester_name',
|
||||
'requester_department',
|
||||
'requester_phone_number',
|
||||
'is_secret',
|
||||
]) {
|
||||
const value = getString(key);
|
||||
if (value) extraFields[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
ticket_id: ticketId,
|
||||
workspace_code: workspaceCode,
|
||||
workspace_name: workspaceCode,
|
||||
title,
|
||||
description,
|
||||
requester_id: getString('requester_id'),
|
||||
requester_tenant_id: getString('requester_tenant_id'),
|
||||
requester_contact: getString('requester_contact'),
|
||||
requester_email: getString('requester_email'),
|
||||
requester_name: getString('requester_name'),
|
||||
requester_department: getString('requester_department'),
|
||||
category_code:
|
||||
getString('category_code') || getString('Category') || 'GENERAL',
|
||||
ticket_type: 'GENERAL',
|
||||
requires_approval: false,
|
||||
is_secret: getFeedbackFlag(feedback, 'is_secret'),
|
||||
status_code: feedbackStatus,
|
||||
feedback_status: feedbackStatus,
|
||||
approval_status: 'NOT_REQUIRED',
|
||||
sync_status: 'SYNCED',
|
||||
issue_link_status: issues.length > 0 ? 'LINKED' : 'NOT_LINKED',
|
||||
external_issue_status: currentIssueStatus,
|
||||
created_at:
|
||||
typeof feedback.createdAt === 'string' ?
|
||||
feedback.createdAt
|
||||
: new Date().toISOString(),
|
||||
updated_at:
|
||||
typeof feedback.updatedAt === 'string' ?
|
||||
feedback.updatedAt
|
||||
: new Date().toISOString(),
|
||||
extra_fields: extraFields,
|
||||
attachments: getAbcFeedbackAttachments(feedback),
|
||||
activity: [],
|
||||
};
|
||||
};
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ?
|
||||
req.query.workspaceCode
|
||||
: '';
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (workspaceCode) {
|
||||
query.set('workspaceCode', workspaceCode);
|
||||
}
|
||||
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({
|
||||
message: '로그인 세션을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const feedback = await findAbcSupportFeedback(workspaceCode, ticketId);
|
||||
if (
|
||||
!canManageSupportFeedback(principal) &&
|
||||
isFeedbackSecret(feedback) &&
|
||||
!isFeedbackOwnedBy(feedback, principal)
|
||||
) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const ticket = await synthesizeAbcTicketFromFeedback(
|
||||
workspaceCode,
|
||||
ticketId,
|
||||
);
|
||||
const isCurrentRequester =
|
||||
ticket.requester_id === principal.user_id ||
|
||||
ticket.requester_email === principal.email;
|
||||
return res.status(200).json({
|
||||
...ticket,
|
||||
requester_name:
|
||||
ticket.requester_name ||
|
||||
(isCurrentRequester ? (principal.name ?? '') : ''),
|
||||
requester_email:
|
||||
ticket.requester_email ||
|
||||
(isCurrentRequester ? (principal.email ?? '') : ''),
|
||||
});
|
||||
} catch {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}${suffix}`,
|
||||
{ headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
|
||||
if (!response.ok && workspaceCode) {
|
||||
try {
|
||||
const recovered = await synthesizeSupportTicketFromAbc(
|
||||
workspaceCode,
|
||||
ticketId,
|
||||
);
|
||||
return res.status(200).json(recovered);
|
||||
} catch {
|
||||
return res.status(response.status).json(data);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok || !workspaceCode) {
|
||||
return res.status(response.status).json(data);
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await applyCurrentAbcIssueState(
|
||||
workspaceCode,
|
||||
data as Record<string, unknown>,
|
||||
ticketId,
|
||||
);
|
||||
return res.status(response.status).json(resolved);
|
||||
} catch {
|
||||
return res.status(response.status).json(data);
|
||||
}
|
||||
} catch {
|
||||
const ticket = getSupportTicketDetail(ticketId);
|
||||
|
||||
if (ticket) {
|
||||
return res.status(200).json(ticket);
|
||||
}
|
||||
|
||||
if (!workspaceCode) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const recovered = await synthesizeSupportTicketFromAbc(
|
||||
workspaceCode,
|
||||
ticketId,
|
||||
);
|
||||
return res.status(200).json(recovered);
|
||||
} catch {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
}
|
||||
},
|
||||
PUT: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ?
|
||||
req.query.workspaceCode
|
||||
: '';
|
||||
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({
|
||||
message: '로그인 세션을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const feedback = await findAbcSupportFeedback(workspaceCode, ticketId);
|
||||
if (
|
||||
!canManageSupportFeedback(principal) &&
|
||||
!isFeedbackOwnedBy(feedback, principal)
|
||||
) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const bodyExtraFields =
|
||||
typeof body.extra_fields === 'object' && body.extra_fields !== null ?
|
||||
(body.extra_fields as Record<string, unknown>)
|
||||
: {};
|
||||
const updateBody = {
|
||||
...body,
|
||||
extra_fields: {
|
||||
requester_id: getFeedbackString(feedback, 'requester_id'),
|
||||
requester_tenant_id: getFeedbackString(
|
||||
feedback,
|
||||
'requester_tenant_id',
|
||||
),
|
||||
requester_email: getFeedbackString(feedback, 'requester_email'),
|
||||
requester_name: getFeedbackString(feedback, 'requester_name'),
|
||||
requester_department: getFeedbackString(
|
||||
feedback,
|
||||
'requester_department',
|
||||
),
|
||||
requester_phone_number: getFeedbackString(
|
||||
feedback,
|
||||
'requester_phone_number',
|
||||
),
|
||||
is_secret: getFeedbackString(feedback, 'is_secret'),
|
||||
...bodyExtraFields,
|
||||
},
|
||||
};
|
||||
await updateAbcSupportFeedback(workspaceCode, ticketId, updateBody);
|
||||
return res
|
||||
.status(200)
|
||||
.json(await synthesizeAbcTicketFromFeedback(workspaceCode, ticketId));
|
||||
} catch (error) {
|
||||
return res.status(404).json({
|
||||
message:
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '지원 요청을 수정할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getSupportAuthHeaders(req),
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
const ticket = getSupportTicketDetail(ticketId);
|
||||
const resolvedWorkspaceCode =
|
||||
workspaceCode !== '' ? workspaceCode : (ticket?.workspace_code ?? '');
|
||||
const abcFeedbackId = Number(
|
||||
ticket?.extra_fields.abc_feedback_id ?? ticketId,
|
||||
);
|
||||
|
||||
if (!resolvedWorkspaceCode || !Number.isFinite(abcFeedbackId)) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
title?: string;
|
||||
description?: string;
|
||||
requester_contact?: string;
|
||||
extra_fields?: Record<string, string>;
|
||||
};
|
||||
const nextBody = {
|
||||
title: body.title ?? ticket?.title ?? '',
|
||||
description: body.description ?? ticket?.description ?? '',
|
||||
requester_contact:
|
||||
body.requester_contact ?? ticket?.requester_contact ?? '',
|
||||
extra_fields: {
|
||||
...(ticket?.extra_fields ?? {}),
|
||||
...(body.extra_fields ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await updateAbcSupportFeedback(
|
||||
resolvedWorkspaceCode,
|
||||
abcFeedbackId,
|
||||
nextBody,
|
||||
);
|
||||
} catch {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const updatedTicket =
|
||||
ticket ?
|
||||
updateSupportTicketStub(ticketId, nextBody)
|
||||
: await synthesizeSupportTicketFromAbc(
|
||||
resolvedWorkspaceCode,
|
||||
abcFeedbackId,
|
||||
);
|
||||
|
||||
return res.status(200).json(updatedTicket);
|
||||
}
|
||||
},
|
||||
DELETE: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ?
|
||||
req.query.workspaceCode
|
||||
: '';
|
||||
|
||||
let isAbcMapped = true;
|
||||
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({
|
||||
message: '로그인 세션을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const feedback = await findAbcSupportFeedback(workspaceCode, ticketId);
|
||||
if (
|
||||
!canManageSupportFeedback(principal) &&
|
||||
!isFeedbackOwnedBy(feedback, principal)
|
||||
) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
try {
|
||||
await deleteAbcSupportFeedback(workspaceCode, ticketId);
|
||||
return res.status(200).json({
|
||||
deleted: true,
|
||||
ticket_id: ticketId,
|
||||
feedback_id: ticketId,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(404).json({
|
||||
message:
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '지원 요청을 삭제할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: getSupportAuthHeaders(req),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 204) {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown;
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
const ticket = getSupportTicketDetail(ticketId);
|
||||
const resolvedWorkspaceCode =
|
||||
workspaceCode !== '' ? workspaceCode : (ticket?.workspace_code ?? '');
|
||||
const abcFeedbackId = Number(
|
||||
ticket?.extra_fields.abc_feedback_id ?? ticketId,
|
||||
);
|
||||
|
||||
if (!resolvedWorkspaceCode || !Number.isFinite(abcFeedbackId)) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAbcSupportFeedback(resolvedWorkspaceCode, abcFeedbackId);
|
||||
} catch {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
deleteSupportTicketStub(ticketId);
|
||||
|
||||
return res.status(200).json({
|
||||
deleted: true,
|
||||
ticket_id: ticketId,
|
||||
feedback_id: abcFeedbackId,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import { getSupportAuthHeaders } from '@/server/support-auth';
|
||||
import { approveSupportTicketStub } from '@/server/support-stub';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
POST: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/approve`,
|
||||
{ method: 'POST', headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
const result = approveSupportTicketStub(ticketId);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import { getSupportAuthHeaders } from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
PATCH: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const query = new URLSearchParams();
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ? req.query.workspaceCode : '';
|
||||
const abcFeedbackId =
|
||||
typeof req.query.abcFeedbackId === 'string' ? req.query.abcFeedbackId : '';
|
||||
|
||||
if (workspaceCode) query.set('workspaceCode', workspaceCode);
|
||||
if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId);
|
||||
|
||||
try {
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/assignee${suffix}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getSupportAuthHeaders(req),
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: '담당자 지정 기능은 secretary-api 연결이 필요합니다.' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import { getSupportAuthHeaders } from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const query = new URLSearchParams();
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ? req.query.workspaceCode : '';
|
||||
const abcFeedbackId =
|
||||
typeof req.query.abcFeedbackId === 'string' ? req.query.abcFeedbackId : '';
|
||||
|
||||
if (workspaceCode) query.set('workspaceCode', workspaceCode);
|
||||
if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId);
|
||||
|
||||
try {
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/assignees${suffix}`,
|
||||
{ headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: '담당자 후보를 조회할 수 없습니다.' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
getSupportAbcTargetConfig,
|
||||
listAbcSupportFeedbackComments,
|
||||
} from '@/server/support-abc';
|
||||
import { getSupportAuthHeaders, getSupportPrincipal } from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const attachmentId = Number(req.query.attachmentId);
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ? req.query.workspaceCode : '';
|
||||
const abcFeedbackId =
|
||||
typeof req.query.abcFeedbackId === 'string' ? req.query.abcFeedbackId : '';
|
||||
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({ message: '로그인 세션을 확인할 수 없습니다.' });
|
||||
}
|
||||
try {
|
||||
const isAdmin = principal.is_admin === true || principal.is_system_admin === true;
|
||||
const comments = await listAbcSupportFeedbackComments(
|
||||
workspaceCode,
|
||||
Number(abcFeedbackId || ticketId),
|
||||
isAdmin,
|
||||
);
|
||||
const attachment = comments
|
||||
.flatMap((comment) => comment.attachments ?? [])
|
||||
.find((item) => item.attachment_id === attachmentId);
|
||||
if (!attachment) {
|
||||
return res.status(404).json({ message: '첨부파일을 찾을 수 없습니다.' });
|
||||
}
|
||||
return res.redirect(302, attachment.download_url);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 첨부파일 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const query = new URLSearchParams();
|
||||
if (workspaceCode) {
|
||||
query.set('workspaceCode', workspaceCode);
|
||||
}
|
||||
if (abcFeedbackId) {
|
||||
query.set('abcFeedbackId', abcFeedbackId);
|
||||
}
|
||||
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/attachments/${attachmentId}${suffix}`,
|
||||
{ headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type') ?? 'application/json';
|
||||
res.status(response.status);
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.send(Buffer.from(await response.arrayBuffer()));
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
const contentLength = response.headers.get('content-length');
|
||||
|
||||
res.status(response.status);
|
||||
res.setHeader('Content-Type', contentType);
|
||||
if (contentDisposition) {
|
||||
res.setHeader('Content-Disposition', contentDisposition);
|
||||
}
|
||||
if (contentLength) {
|
||||
res.setHeader('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
res.send(Buffer.from(await response.arrayBuffer()));
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
createAbcSupportFeedbackComment,
|
||||
getSupportAbcTargetConfig,
|
||||
listAbcSupportFeedbackComments,
|
||||
} from '@/server/support-abc';
|
||||
import {
|
||||
getSupportAuthHeaders,
|
||||
getSupportPrincipal,
|
||||
applySupportCommentPermissions,
|
||||
} from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ?
|
||||
req.query.workspaceCode
|
||||
: '';
|
||||
const abcFeedbackId =
|
||||
typeof req.query.abcFeedbackId === 'string' ?
|
||||
req.query.abcFeedbackId
|
||||
: '';
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (workspaceCode) {
|
||||
query.set('workspaceCode', workspaceCode);
|
||||
}
|
||||
if (abcFeedbackId) {
|
||||
query.set('abcFeedbackId', abcFeedbackId);
|
||||
}
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({ message: '로그인 세션을 확인할 수 없습니다.' });
|
||||
}
|
||||
try {
|
||||
const comments = await listAbcSupportFeedbackComments(
|
||||
workspaceCode,
|
||||
Number(abcFeedbackId ? abcFeedbackId : ticketId),
|
||||
false,
|
||||
);
|
||||
return res.status(200).json(
|
||||
comments.map((comment) =>
|
||||
applySupportCommentPermissions(comment, principal),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 댓글 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/comments${suffix}`,
|
||||
{
|
||||
headers: getSupportAuthHeaders(req),
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: '댓글 기능은 secretary-api 연결이 필요합니다.' });
|
||||
}
|
||||
},
|
||||
POST: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ?
|
||||
req.query.workspaceCode
|
||||
: '';
|
||||
const abcFeedbackId =
|
||||
typeof req.query.abcFeedbackId === 'string' ?
|
||||
req.query.abcFeedbackId
|
||||
: '';
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (workspaceCode) {
|
||||
query.set('workspaceCode', workspaceCode);
|
||||
}
|
||||
if (abcFeedbackId) {
|
||||
query.set('abcFeedbackId', abcFeedbackId);
|
||||
}
|
||||
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({ message: '로그인 세션을 확인할 수 없습니다.' });
|
||||
}
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const content = typeof body.content === 'string' ? body.content.trim() : '';
|
||||
if (!content) {
|
||||
return res.status(400).json({ message: '댓글 내용을 입력해 주세요.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const comment = await createAbcSupportFeedbackComment(
|
||||
workspaceCode,
|
||||
Number(abcFeedbackId || ticketId),
|
||||
{
|
||||
author_id: principal.user_id,
|
||||
author_tenant_id: principal.tenant_id,
|
||||
author_name: principal.name ?? principal.email ?? principal.user_id,
|
||||
content,
|
||||
is_internal: false,
|
||||
actor_type: 'ADMIN',
|
||||
},
|
||||
);
|
||||
return res
|
||||
.status(201)
|
||||
.json(applySupportCommentPermissions(comment, principal));
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 댓글 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/comments${suffix}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getSupportAuthHeaders(req),
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: '댓글 기능은 secretary-api 연결이 필요합니다.' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
deleteAbcSupportFeedbackComment,
|
||||
getSupportAbcTargetConfig,
|
||||
listAbcSupportFeedbackComments,
|
||||
updateAbcSupportFeedbackComment,
|
||||
} from '@/server/support-abc';
|
||||
import {
|
||||
getSupportAuthHeaders,
|
||||
getSupportPrincipal,
|
||||
applySupportCommentPermissions,
|
||||
isSupportAdmin,
|
||||
} from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
PUT: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const commentId = Number(req.query.commentId);
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ?
|
||||
req.query.workspaceCode
|
||||
: '';
|
||||
const abcFeedbackId =
|
||||
typeof req.query.abcFeedbackId === 'string' ?
|
||||
req.query.abcFeedbackId
|
||||
: '';
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (workspaceCode) query.set('workspaceCode', workspaceCode);
|
||||
if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId);
|
||||
|
||||
try {
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({ message: '로그인 세션을 확인할 수 없습니다.' });
|
||||
}
|
||||
const feedbackId = Number(abcFeedbackId ? abcFeedbackId : ticketId);
|
||||
const comments = await listAbcSupportFeedbackComments(workspaceCode, feedbackId, true);
|
||||
const comment = comments.find((item) => item.comment_id === commentId);
|
||||
if (!comment) {
|
||||
return res.status(404).json({ message: '댓글을 찾을 수 없습니다.' });
|
||||
}
|
||||
const isAdmin = isSupportAdmin(principal);
|
||||
const isAuthor =
|
||||
comment.author_id === principal.user_id &&
|
||||
comment.author_tenant_id === principal.tenant_id;
|
||||
if (!isAdmin && !isAuthor) {
|
||||
return res.status(403).json({ message: '댓글을 수정할 권한이 없습니다.' });
|
||||
}
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const content = typeof body.content === 'string' ? body.content.trim() : '';
|
||||
if (!content) {
|
||||
return res.status(400).json({ message: '댓글 내용을 입력해 주세요.' });
|
||||
}
|
||||
try {
|
||||
const updatedComment = await updateAbcSupportFeedbackComment(
|
||||
workspaceCode,
|
||||
feedbackId,
|
||||
commentId,
|
||||
content,
|
||||
);
|
||||
return res
|
||||
.status(200)
|
||||
.json(applySupportCommentPermissions(updatedComment, principal));
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 댓글 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/comments/${commentId}${suffix}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getSupportAuthHeaders(req),
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: '댓글 기능은 secretary-api 연결이 필요합니다.' });
|
||||
}
|
||||
},
|
||||
DELETE: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const commentId = Number(req.query.commentId);
|
||||
const requesterId =
|
||||
typeof req.query.requesterId === 'string' ? req.query.requesterId : '';
|
||||
const requesterTenantId =
|
||||
typeof req.query.requesterTenantId === 'string' ?
|
||||
req.query.requesterTenantId
|
||||
: '';
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ?
|
||||
req.query.workspaceCode
|
||||
: '';
|
||||
const abcFeedbackId =
|
||||
typeof req.query.abcFeedbackId === 'string' ?
|
||||
req.query.abcFeedbackId
|
||||
: '';
|
||||
const query = new URLSearchParams({
|
||||
requesterId,
|
||||
requesterTenantId,
|
||||
});
|
||||
if (workspaceCode) query.set('workspaceCode', workspaceCode);
|
||||
if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId);
|
||||
|
||||
try {
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({ message: '로그인 세션을 확인할 수 없습니다.' });
|
||||
}
|
||||
const feedbackId = Number(abcFeedbackId ? abcFeedbackId : ticketId);
|
||||
const comments = await listAbcSupportFeedbackComments(workspaceCode, feedbackId, true);
|
||||
const comment = comments.find((item) => item.comment_id === commentId);
|
||||
if (!comment) {
|
||||
return res.status(404).json({ message: '댓글을 찾을 수 없습니다.' });
|
||||
}
|
||||
const isAdmin = isSupportAdmin(principal);
|
||||
const isAuthor =
|
||||
comment.author_id === principal.user_id &&
|
||||
comment.author_tenant_id === principal.tenant_id;
|
||||
if (!isAdmin && !isAuthor) {
|
||||
return res.status(403).json({ message: '댓글을 삭제할 권한이 없습니다.' });
|
||||
}
|
||||
try {
|
||||
await deleteAbcSupportFeedbackComment(workspaceCode, feedbackId, commentId);
|
||||
return res.status(200).json({ deleted: true, comment_id: commentId });
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 댓글 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/comments/${commentId}?${query.toString()}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: getSupportAuthHeaders(req),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 204) {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown;
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: '댓글 기능은 secretary-api 연결이 필요합니다.' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { NextApiRequest } from 'next';
|
||||
import formidable from 'formidable';
|
||||
import type { File as FormidableFile, Files as FormidableFiles } from 'formidable';
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
createAbcSupportFeedbackCommentWithAttachments,
|
||||
getSupportAbcTargetConfig,
|
||||
} from '@/server/support-abc';
|
||||
import { getSupportAuthHeaders, getSupportPrincipal } from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
const MAX_UPLOAD_SIZE_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
const parseMultipart = (req: NextApiRequest) => {
|
||||
return new Promise<{ content: string; isInternal: boolean; files: FormidableFile[] }>(
|
||||
(resolve, reject) => {
|
||||
const form = formidable({
|
||||
multiples: true,
|
||||
maxFiles: 10,
|
||||
maxFileSize: MAX_UPLOAD_SIZE_BYTES,
|
||||
maxTotalFileSize: MAX_UPLOAD_SIZE_BYTES,
|
||||
keepExtensions: true,
|
||||
});
|
||||
|
||||
form.parse(req, (error, fields, files: FormidableFiles) => {
|
||||
if (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
|
||||
const getFirstField = (value: unknown) => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||||
return '';
|
||||
};
|
||||
const attachmentFiles = Object.entries(files)
|
||||
.filter(([fieldName]) => fieldName === 'attachments')
|
||||
.flatMap(([, value]) => (Array.isArray(value) ? value : [value]).filter((file): file is FormidableFile => file !== undefined));
|
||||
|
||||
resolve({
|
||||
content: getFirstField(fields.content),
|
||||
isInternal: getFirstField(fields.is_internal) === 'true',
|
||||
files: attachmentFiles,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
POST: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ? req.query.workspaceCode : '';
|
||||
const abcFeedbackId =
|
||||
typeof req.query.abcFeedbackId === 'string' ? req.query.abcFeedbackId : '';
|
||||
const query = new URLSearchParams();
|
||||
if (workspaceCode) query.set('workspaceCode', workspaceCode);
|
||||
if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId);
|
||||
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
|
||||
if (isAbcMapped) {
|
||||
try {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({ message: '로그인 세션을 확인할 수 없습니다.' });
|
||||
}
|
||||
const { content, isInternal, files } = await parseMultipart(req);
|
||||
if (isInternal) {
|
||||
return res.status(400).json({ message: '내부 메모 첨부파일은 별도 저장 경로가 필요합니다.' });
|
||||
}
|
||||
if (!content.trim()) {
|
||||
return res.status(400).json({ message: '댓글 내용을 입력해 주세요.' });
|
||||
}
|
||||
const uploadFiles = await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
buffer: await readFile(file.filepath),
|
||||
filename: file.originalFilename ?? 'comment-image',
|
||||
mimetype: file.mimetype ?? 'application/octet-stream',
|
||||
})),
|
||||
);
|
||||
const data = await createAbcSupportFeedbackCommentWithAttachments(
|
||||
workspaceCode,
|
||||
Number(abcFeedbackId || ticketId),
|
||||
{
|
||||
author_id: principal.user_id,
|
||||
author_tenant_id: principal.tenant_id,
|
||||
author_name: principal.name ?? principal.email ?? principal.user_id,
|
||||
content: content.trim(),
|
||||
is_internal: false,
|
||||
actor_type: 'ADMIN',
|
||||
},
|
||||
uploadFiles,
|
||||
);
|
||||
return res.status(201).json(data);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 댓글 첨부파일 업로드에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { content, isInternal, files } = await parseMultipart(req);
|
||||
const formData = new FormData();
|
||||
formData.append('content', content);
|
||||
formData.append('is_internal', String(isInternal));
|
||||
|
||||
for (const file of files) {
|
||||
const buffer = await readFile(file.filepath);
|
||||
formData.append(
|
||||
'attachments',
|
||||
new Blob([buffer], { type: file.mimetype ?? 'application/octet-stream' }),
|
||||
file.originalFilename ?? 'comment-image',
|
||||
);
|
||||
}
|
||||
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/comments/attachments${suffix}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getSupportAuthHeaders(req),
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
return res.status(response.status).json(data);
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
message: error instanceof Error ? error.message : '댓글 첨부파일 업로드에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
getSupportAbcTargetConfig,
|
||||
updateAbcSupportFeedbackStatus,
|
||||
} from '@/server/support-abc';
|
||||
import { getSupportPrincipal } from '@/server/support-auth';
|
||||
|
||||
const FEEDBACK_STATUS_VALUES = new Set([
|
||||
'INIT',
|
||||
'ON_REVIEW',
|
||||
'DETAILED_REVIEW',
|
||||
'IN_PROGRESS',
|
||||
'RESOLVED',
|
||||
'PENDING',
|
||||
]);
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
PATCH: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const workspaceCode =
|
||||
typeof req.query.workspaceCode === 'string' ?
|
||||
req.query.workspaceCode
|
||||
: '';
|
||||
const feedbackId =
|
||||
typeof req.query.abcFeedbackId === 'string' ?
|
||||
Number(req.query.abcFeedbackId)
|
||||
: ticketId;
|
||||
const principal = await getSupportPrincipal(req);
|
||||
|
||||
if (!principal) {
|
||||
return res.status(401).json({
|
||||
message: '로그인 세션을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (principal.is_admin !== true && principal.is_system_admin !== true) {
|
||||
return res.status(403).json({
|
||||
message: '관리자만 피드백 상태를 변경할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!workspaceCode || !Number.isInteger(feedbackId) || feedbackId <= 0) {
|
||||
return res.status(400).json({ message: 'ABC 피드백 식별자가 필요합니다.' });
|
||||
}
|
||||
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
return res.status(404).json({ message: 'ABC 피드백 workspace를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const requestedStatus =
|
||||
typeof body.feedback_status === 'string' ?
|
||||
body.feedback_status
|
||||
: '';
|
||||
if (!FEEDBACK_STATUS_VALUES.has(requestedStatus)) {
|
||||
return res.status(400).json({ message: '지원하지 않는 피드백 상태입니다.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const savedStatus = await updateAbcSupportFeedbackStatus(
|
||||
workspaceCode,
|
||||
feedbackId,
|
||||
requestedStatus,
|
||||
);
|
||||
return res.status(200).json({
|
||||
feedback_id: feedbackId,
|
||||
feedback_status: savedStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message:
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: 'ABC 피드백 상태 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
export default handler;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { NextApiRequest } from 'next';
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
createAbcSupportFeedbackComment,
|
||||
getSupportAbcTargetConfig,
|
||||
listAbcSupportFeedbackComments,
|
||||
} from '@/server/support-abc';
|
||||
import { getSupportAuthHeaders, getSupportPrincipal } from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const getQuery = (req: NextApiRequest) => {
|
||||
const query = new URLSearchParams();
|
||||
if (typeof req.query.workspaceCode === 'string') query.set('workspaceCode', req.query.workspaceCode);
|
||||
if (typeof req.query.abcFeedbackId === 'string') query.set('abcFeedbackId', req.query.abcFeedbackId);
|
||||
return query.toString() ? `?${query.toString()}` : '';
|
||||
};
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
try {
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(String(req.query.workspaceCode ?? ''));
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal || !(principal.is_admin || principal.is_system_admin)) {
|
||||
return res.status(principal ? 403 : 401).json({
|
||||
message: principal ? '내부 메모를 조회할 권한이 없습니다.' : '로그인 세션을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const workspaceCode = String(req.query.workspaceCode);
|
||||
const feedbackId = Number(req.query.abcFeedbackId ?? ticketId);
|
||||
try {
|
||||
const comments = await listAbcSupportFeedbackComments(workspaceCode, feedbackId, true);
|
||||
return res.status(200).json(comments.filter((comment) => comment.is_internal));
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 내부 메모 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/internal-memos${getQuery(req)}`,
|
||||
{ headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
return res.status(response.status).json(await response.json());
|
||||
} catch {
|
||||
return res.status(503).json({ message: '내부 메모를 조회할 수 없습니다.' });
|
||||
}
|
||||
},
|
||||
POST: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
try {
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(String(req.query.workspaceCode ?? ''));
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal || !(principal.is_admin || principal.is_system_admin)) {
|
||||
return res.status(principal ? 403 : 401).json({
|
||||
message: principal ? '내부 메모를 작성할 권한이 없습니다.' : '로그인 세션을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const workspaceCode = String(req.query.workspaceCode);
|
||||
const feedbackId = Number(req.query.abcFeedbackId ?? ticketId);
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const content = typeof body.content === 'string' ? body.content.trim() : '';
|
||||
if (!content) {
|
||||
return res.status(400).json({ message: '내부 메모 내용을 입력해 주세요.' });
|
||||
}
|
||||
try {
|
||||
return res.status(201).json(
|
||||
await createAbcSupportFeedbackComment(workspaceCode, feedbackId, {
|
||||
author_id: principal.user_id,
|
||||
author_tenant_id: principal.tenant_id,
|
||||
author_name: principal.name ?? principal.email ?? principal.user_id,
|
||||
content,
|
||||
is_internal: true,
|
||||
actor_type: 'ADMIN',
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 내부 메모 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/internal-memos${getQuery(req)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getSupportAuthHeaders(req) },
|
||||
body: JSON.stringify(req.body),
|
||||
},
|
||||
);
|
||||
return res.status(response.status).json(await response.json());
|
||||
} catch {
|
||||
return res.status(503).json({ message: '내부 메모를 저장할 수 없습니다.' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { NextApiRequest } from 'next';
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
deleteAbcSupportFeedbackComment,
|
||||
getSupportAbcTargetConfig,
|
||||
listAbcSupportFeedbackComments,
|
||||
updateAbcSupportFeedbackComment,
|
||||
} from '@/server/support-abc';
|
||||
import { getSupportAuthHeaders, getSupportPrincipal } from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const getQuery = (req: NextApiRequest) => {
|
||||
const query = new URLSearchParams();
|
||||
if (typeof req.query.workspaceCode === 'string') query.set('workspaceCode', req.query.workspaceCode);
|
||||
if (typeof req.query.abcFeedbackId === 'string') query.set('abcFeedbackId', req.query.abcFeedbackId);
|
||||
return query.toString() ? `?${query.toString()}` : '';
|
||||
};
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
PUT: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const memoId = Number(req.query.memoId);
|
||||
try {
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(String(req.query.workspaceCode ?? ''));
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal || !(principal.is_admin || principal.is_system_admin)) {
|
||||
return res.status(principal ? 403 : 401).json({
|
||||
message: principal ? '내부 메모를 수정할 권한이 없습니다.' : '로그인 세션을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const workspaceCode = String(req.query.workspaceCode);
|
||||
const feedbackId = Number(req.query.abcFeedbackId ?? ticketId);
|
||||
const comments = await listAbcSupportFeedbackComments(workspaceCode, feedbackId, true);
|
||||
const memo = comments.find((comment) => comment.comment_id === memoId && comment.is_internal);
|
||||
if (!memo) return res.status(404).json({ message: '내부 메모를 찾을 수 없습니다.' });
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const content = typeof body.content === 'string' ? body.content.trim() : '';
|
||||
if (!content) return res.status(400).json({ message: '내부 메모 내용을 입력해 주세요.' });
|
||||
try {
|
||||
return res.status(200).json(
|
||||
await updateAbcSupportFeedbackComment(workspaceCode, feedbackId, memoId, content),
|
||||
);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 내부 메모 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/internal-memos/${memoId}${getQuery(req)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...getSupportAuthHeaders(req) },
|
||||
body: JSON.stringify(req.body),
|
||||
},
|
||||
);
|
||||
return res.status(response.status).json(await response.json());
|
||||
} catch {
|
||||
return res.status(503).json({ message: '내부 메모를 수정할 수 없습니다.' });
|
||||
}
|
||||
},
|
||||
DELETE: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
const memoId = Number(req.query.memoId);
|
||||
try {
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(String(req.query.workspaceCode ?? ''));
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal || !(principal.is_admin || principal.is_system_admin)) {
|
||||
return res.status(principal ? 403 : 401).json({
|
||||
message: principal ? '내부 메모를 삭제할 권한이 없습니다.' : '로그인 세션을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const workspaceCode = String(req.query.workspaceCode);
|
||||
const feedbackId = Number(req.query.abcFeedbackId ?? ticketId);
|
||||
const comments = await listAbcSupportFeedbackComments(workspaceCode, feedbackId, true);
|
||||
const memo = comments.find((comment) => comment.comment_id === memoId && comment.is_internal);
|
||||
if (!memo) return res.status(404).json({ message: '내부 메모를 찾을 수 없습니다.' });
|
||||
try {
|
||||
await deleteAbcSupportFeedbackComment(workspaceCode, feedbackId, memoId);
|
||||
return res.status(200).json({ deleted: true, comment_id: memoId });
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message: error instanceof Error ? error.message : 'ABC 내부 메모 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/internal-memos/${memoId}${getQuery(req)}`,
|
||||
{ method: 'DELETE', headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
return res.status(response.status).json(await response.json());
|
||||
} catch {
|
||||
return res.status(503).json({ message: '내부 메모를 삭제할 수 없습니다.' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import { getSupportAuthHeaders } from '@/server/support-auth';
|
||||
import { createSupportIssueStub } from '@/server/support-stub';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
POST: async (req, res) => {
|
||||
const ticketId = Number(req.query.ticketId);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/tickets/${ticketId}/issue`,
|
||||
{ method: 'POST', headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
const result = createSupportIssueStub(ticketId);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ message: '지원 요청을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import { getSupportAuthHeaders } from '@/server/support-auth';
|
||||
import { listAllSupportTickets } from '@/server/support-stub';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${supportApiBaseUrl}/api/tickets`, {
|
||||
headers: getSupportAuthHeaders(req),
|
||||
});
|
||||
const data = (await response.json()) as unknown;
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res.status(200).json(listAllSupportTickets());
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import { getSupportAuthHeaders } from '@/server/support-auth';
|
||||
import { listSupportWorkspaces } from '@/server/support-stub';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${supportApiBaseUrl}/api/workspaces`, {
|
||||
headers: getSupportAuthHeaders(req),
|
||||
});
|
||||
const data = (await response.json()) as unknown;
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res.status(200).json(listSupportWorkspaces());
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
getSupportAbcTargetConfig,
|
||||
listSupportAbcFields,
|
||||
} from '@/server/support-abc';
|
||||
import { getSupportAuthHeaders } from '@/server/support-auth';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
|
||||
const toPublicFieldCode = (fieldKey: string) => {
|
||||
if (fieldKey.toLowerCase() === 'ip') return 'ip_address';
|
||||
if (fieldKey.toLowerCase().replace(/[_-]/g, '') === 'macaddress') {
|
||||
return 'mac_address';
|
||||
}
|
||||
if (fieldKey.toLowerCase() === 'category') return 'category';
|
||||
return fieldKey;
|
||||
};
|
||||
|
||||
const toWorkspaceFormTemplate = (
|
||||
workspaceCode: string,
|
||||
fields: Awaited<ReturnType<typeof listSupportAbcFields>>,
|
||||
) => {
|
||||
const visibleFields = fields
|
||||
.filter((field) => field.status === 'ACTIVE')
|
||||
.filter((field) => !['images', 'aiField'].includes(field.format))
|
||||
.sort((left, right) => {
|
||||
const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER;
|
||||
return leftOrder - rightOrder || left.id - right.id;
|
||||
});
|
||||
|
||||
return {
|
||||
workspace_code: workspaceCode,
|
||||
workspace_name: workspaceCode,
|
||||
requires_approval: false,
|
||||
fields: visibleFields.map((field) => ({
|
||||
field_code:
|
||||
field.key === 'contents' || field.key === 'message'
|
||||
? 'description'
|
||||
: toPublicFieldCode(field.key),
|
||||
label: field.name,
|
||||
field_type:
|
||||
field.key === 'contents' || field.key === 'message'
|
||||
? 'textarea'
|
||||
: field.format === 'select' || field.format === 'multiSelect'
|
||||
? 'select'
|
||||
: 'text',
|
||||
required:
|
||||
['title', 'contents', 'message'].includes(field.key) ||
|
||||
toPublicFieldCode(field.key) === 'category',
|
||||
options: field.options ?? [],
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
const workspaceCode = req.query.workspaceCode as string;
|
||||
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
const fields = await listSupportAbcFields(workspaceCode);
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json(toWorkspaceFormTemplate(workspaceCode, fields));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.startsWith('No ABC mapping configured')
|
||||
) {
|
||||
// Non-feedback workspaces still use the Secretary form definition.
|
||||
} else {
|
||||
return res.status(502).json({
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'ABC feedback form fields could not be loaded',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/workspaces/${encodeURIComponent(workspaceCode)}/form-template`,
|
||||
{ headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
const data = (await response.json()) as unknown;
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
return res.status(502).json({
|
||||
message: 'Support workspace form template could not be loaded',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { NextApiRequest } from 'next';
|
||||
import formidable from 'formidable';
|
||||
import type {
|
||||
Fields as FormidableFields,
|
||||
File as FormidableFile,
|
||||
Files as FormidableFiles,
|
||||
} from 'formidable';
|
||||
|
||||
import { createNextApiHandler } from '@/server/api-handler';
|
||||
import {
|
||||
createAbcSupportFeedback,
|
||||
createAbcSupportFeedbackWithImages,
|
||||
getAbcFeedbackContent,
|
||||
getSupportAbcTargetConfig,
|
||||
listAbcSupportFeedbacks,
|
||||
} from '@/server/support-abc';
|
||||
import {
|
||||
getSupportAuthHeaders,
|
||||
getSupportPrincipal,
|
||||
} from '@/server/support-auth';
|
||||
import type { SupportPrincipal } from '@/server/support-auth';
|
||||
import {
|
||||
createSupportTicketStub,
|
||||
getSupportWorkspaceTemplate,
|
||||
listSupportTickets,
|
||||
markSupportTicketSyncedStub,
|
||||
} from '@/server/support-stub';
|
||||
import type { SupportTicketRecord } from '@/server/support-types';
|
||||
|
||||
const supportApiBaseUrl =
|
||||
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010';
|
||||
const MAX_UPLOAD_SIZE_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
const parseJsonBody = async (req: NextApiRequest) => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of req) {
|
||||
chunks.push(
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array),
|
||||
);
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
||||
return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
};
|
||||
|
||||
const parseMultipartBody = async (req: NextApiRequest) => {
|
||||
return new Promise<{
|
||||
fields: Record<string, string | string[]>;
|
||||
files: FormidableFile[];
|
||||
}>((resolve, reject) => {
|
||||
const form = formidable({
|
||||
multiples: true,
|
||||
maxFiles: 10,
|
||||
maxFileSize: MAX_UPLOAD_SIZE_BYTES,
|
||||
maxTotalFileSize: MAX_UPLOAD_SIZE_BYTES,
|
||||
keepExtensions: true,
|
||||
});
|
||||
|
||||
form.parse(
|
||||
req,
|
||||
(
|
||||
error: Error | null,
|
||||
fields: FormidableFields,
|
||||
files: FormidableFiles,
|
||||
) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const attachmentFiles = Object.entries(files)
|
||||
.filter(([fieldName]) => fieldName === 'attachments')
|
||||
.flatMap(([, value]) => (Array.isArray(value) ? value : [value]))
|
||||
.filter((file): file is FormidableFile => file !== undefined);
|
||||
|
||||
resolve({
|
||||
fields: fields as Record<string, string | string[]>,
|
||||
files: attachmentFiles,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const flattenMultipartFields = (fields: Record<string, string | string[]>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) && value.length === 1 ? value[0] : value,
|
||||
]),
|
||||
);
|
||||
|
||||
const readJsonOrMessage = async (response: Response) => {
|
||||
const raw = await response.text();
|
||||
|
||||
if (!raw.trim()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {
|
||||
message: raw.trim().slice(0, 1000),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isUploadSizeError = (error: unknown) => {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
|
||||
const candidate = error as {
|
||||
code?: unknown;
|
||||
httpCode?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const message =
|
||||
typeof candidate.message === 'string' ? candidate.message : '';
|
||||
|
||||
return (
|
||||
candidate.httpCode === 413 ||
|
||||
candidate.code === 'ETOOBIG' ||
|
||||
/(?:file|request).*(?:too large|max.*size)|maxTotalFileSize|maxFileSize/i.test(
|
||||
message,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const toAbcRequesterFields = (principal: SupportPrincipal) => ({
|
||||
requester_id: principal.user_id,
|
||||
requester_tenant_id: principal.tenant_id,
|
||||
requester_email: principal.email ?? '',
|
||||
requester_name: principal.name ?? '',
|
||||
requester_department: principal.department ?? '',
|
||||
requester_phone_number: principal.phone_number ?? '',
|
||||
});
|
||||
|
||||
const getFeedbackString = (feedback: Record<string, unknown>, key: string) =>
|
||||
typeof feedback[key] === 'string' ? feedback[key] : '';
|
||||
|
||||
const getFeedbackFlag = (feedback: Record<string, unknown>, key: string) => {
|
||||
const value = feedback[key];
|
||||
return (
|
||||
value === true ||
|
||||
(typeof value === 'string' && ['true', '1'].includes(value.toLowerCase()))
|
||||
);
|
||||
};
|
||||
|
||||
const getLegacyRequester = (feedback: Record<string, unknown>) => {
|
||||
const requester = feedback.requester;
|
||||
return requester && typeof requester === 'object' ?
|
||||
(requester as Record<string, unknown>)
|
||||
: null;
|
||||
};
|
||||
|
||||
const forwardMultipartTicketCreate = async (
|
||||
workspaceCode: string,
|
||||
fields: Record<string, string | string[]>,
|
||||
files: FormidableFile[],
|
||||
headers: Record<string, string>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
formData.append(key, item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
formData.append(key, value);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const fileBuffer = await readFile(file.filepath);
|
||||
formData.append(
|
||||
'attachments',
|
||||
new Blob([new Uint8Array(fileBuffer)], {
|
||||
type: file.mimetype ?? 'application/octet-stream',
|
||||
}),
|
||||
file.originalFilename ?? 'attachment.bin',
|
||||
);
|
||||
}
|
||||
|
||||
return fetch(
|
||||
`${supportApiBaseUrl}/api/workspaces/${encodeURIComponent(workspaceCode)}/tickets`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const buildFallbackTicketList = (
|
||||
workspaceCode: string,
|
||||
requesterId: string | undefined,
|
||||
requesterTenantId: string | undefined,
|
||||
abcFeedbacks: Record<string, unknown>[],
|
||||
) => {
|
||||
const fallbackTickets = listSupportTickets({
|
||||
workspace_code: workspaceCode,
|
||||
requester_id: requesterId,
|
||||
requester_tenant_id: requesterTenantId,
|
||||
});
|
||||
|
||||
const template = getSupportWorkspaceTemplate(workspaceCode);
|
||||
const resolvedRequesterId = requesterId ?? '';
|
||||
const resolvedRequesterTenantId = requesterTenantId ?? '';
|
||||
|
||||
const abcTickets: SupportTicketRecord[] = abcFeedbacks.map((feedback) => {
|
||||
const ticketId = Number(feedback.id);
|
||||
const { title, description } = getAbcFeedbackContent(feedback);
|
||||
const createdAt =
|
||||
typeof feedback.createdAt === 'string' ?
|
||||
feedback.createdAt
|
||||
: new Date().toISOString();
|
||||
const updatedAt =
|
||||
typeof feedback.updatedAt === 'string' ? feedback.updatedAt : createdAt;
|
||||
|
||||
return {
|
||||
ticket_id: ticketId,
|
||||
workspace_code: workspaceCode,
|
||||
workspace_name: template.workspace_name,
|
||||
title,
|
||||
description,
|
||||
requester_id: resolvedRequesterId,
|
||||
requester_tenant_id: resolvedRequesterTenantId,
|
||||
requester_contact: '',
|
||||
category_code: 'GENERAL',
|
||||
ticket_type: template.requires_approval ? 'APPROVAL' : 'GENERAL',
|
||||
requires_approval: template.requires_approval,
|
||||
status_code: 'RECEIVED',
|
||||
feedback_status: 'INIT',
|
||||
approval_status: template.requires_approval ? 'PENDING' : 'NOT_REQUIRED',
|
||||
sync_status: 'SYNCED',
|
||||
issue_link_status: 'NOT_LINKED',
|
||||
external_issue_status: null,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
extra_fields: {
|
||||
abc_feedback_id: String(ticketId),
|
||||
},
|
||||
activity: [],
|
||||
};
|
||||
});
|
||||
|
||||
const unsyncedTickets = fallbackTickets.filter(
|
||||
(ticket) => ticket.sync_status !== 'SYNCED',
|
||||
);
|
||||
|
||||
return [...unsyncedTickets, ...abcTickets];
|
||||
};
|
||||
|
||||
const getMappedAbcFeedbackId = (ticket: SupportTicketRecord) => {
|
||||
const extraFields = ticket.extra_fields;
|
||||
const mappedId = extraFields.abc_feedback_id ?? ticket.ticket_id;
|
||||
|
||||
return typeof mappedId === 'string' || typeof mappedId === 'number' ?
|
||||
String(mappedId)
|
||||
: null;
|
||||
};
|
||||
|
||||
const overlayAbcFeedbackContent = (
|
||||
tickets: SupportTicketRecord[],
|
||||
abcFeedbacks: Record<string, unknown>[],
|
||||
) => {
|
||||
const feedbackById = new Map(
|
||||
abcFeedbacks
|
||||
.filter(
|
||||
(feedback) =>
|
||||
typeof feedback.id === 'string' || typeof feedback.id === 'number',
|
||||
)
|
||||
.map((feedback) => [String(feedback.id), feedback]),
|
||||
);
|
||||
|
||||
return tickets.map((ticket) => {
|
||||
const feedback = feedbackById.get(getMappedAbcFeedbackId(ticket) ?? '');
|
||||
|
||||
if (!feedback) {
|
||||
return ticket;
|
||||
}
|
||||
|
||||
const { title, description } = getAbcFeedbackContent(feedback);
|
||||
|
||||
return {
|
||||
...ticket,
|
||||
title: title || ticket.title,
|
||||
description: description || ticket.description,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const mergeTicketsWithAbcFallback = async (
|
||||
workspaceCode: string,
|
||||
requesterId: string | undefined,
|
||||
requesterTenantId: string | undefined,
|
||||
tickets: SupportTicketRecord[],
|
||||
) => {
|
||||
const abcFeedbacks = await listAbcSupportFeedbacks(workspaceCode);
|
||||
const fallbackTickets = buildFallbackTicketList(
|
||||
workspaceCode,
|
||||
requesterId,
|
||||
requesterTenantId,
|
||||
abcFeedbacks,
|
||||
);
|
||||
const canonicalTickets = overlayAbcFeedbackContent(tickets, abcFeedbacks);
|
||||
|
||||
const mappedAbcFeedbackIds = new Set(
|
||||
canonicalTickets
|
||||
.map(getMappedAbcFeedbackId)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
|
||||
const additionalFallbackTickets = fallbackTickets.filter((ticket) => {
|
||||
const abcFeedbackId = ticket.extra_fields.abc_feedback_id;
|
||||
|
||||
if (
|
||||
typeof abcFeedbackId !== 'string' &&
|
||||
typeof abcFeedbackId !== 'number'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !mappedAbcFeedbackIds.has(String(abcFeedbackId));
|
||||
});
|
||||
|
||||
return [...canonicalTickets, ...additionalFallbackTickets].sort(
|
||||
(left, right) => {
|
||||
return (
|
||||
new Date(right.created_at).getTime() -
|
||||
new Date(left.created_at).getTime()
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handler = createNextApiHandler({
|
||||
GET: async (req, res) => {
|
||||
const workspaceCode = req.query.workspaceCode as string;
|
||||
const requesterId = req.query.requesterId as string | undefined;
|
||||
const requesterTenantId = req.query.requesterTenantId as string | undefined;
|
||||
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({
|
||||
message: '로그인 세션을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const feedbacks = await listAbcSupportFeedbacks(workspaceCode);
|
||||
// Secret feedbacks remain visible in the list; detail access is enforced by the detail API.
|
||||
const visibleFeedbacks = feedbacks;
|
||||
const assigneeByFeedbackId = new Map<
|
||||
string,
|
||||
{
|
||||
assignee_id?: string | null;
|
||||
assignee_tenant_id?: string | null;
|
||||
assignee_name?: string | null;
|
||||
assignee_email?: string | null;
|
||||
}
|
||||
>();
|
||||
try {
|
||||
const assignmentResponse = await fetch(
|
||||
`${supportApiBaseUrl}/api/workspaces/${encodeURIComponent(workspaceCode)}/tickets`,
|
||||
{ headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
const assignmentData = (await assignmentResponse.json()) as unknown;
|
||||
if (assignmentResponse.ok && Array.isArray(assignmentData)) {
|
||||
for (const value of assignmentData) {
|
||||
if (typeof value !== 'object' || value === null) continue;
|
||||
const record = value as Record<string, unknown>;
|
||||
const assignee = {
|
||||
assignee_id:
|
||||
typeof record.assignee_id === 'string' ?
|
||||
record.assignee_id
|
||||
: null,
|
||||
assignee_tenant_id:
|
||||
typeof record.assignee_tenant_id === 'string' ?
|
||||
record.assignee_tenant_id
|
||||
: null,
|
||||
assignee_name:
|
||||
typeof record.assignee_name === 'string' ?
|
||||
record.assignee_name
|
||||
: null,
|
||||
assignee_email:
|
||||
typeof record.assignee_email === 'string' ?
|
||||
record.assignee_email
|
||||
: null,
|
||||
};
|
||||
const ids = [] as string[];
|
||||
if (
|
||||
typeof record.ticket_id === 'string' ||
|
||||
typeof record.ticket_id === 'number'
|
||||
) {
|
||||
ids.push(String(record.ticket_id));
|
||||
}
|
||||
if (
|
||||
typeof record.extra_fields === 'object' &&
|
||||
record.extra_fields !== null
|
||||
) {
|
||||
const abcFeedbackId = (
|
||||
record.extra_fields as Record<string, unknown>
|
||||
).abc_feedback_id;
|
||||
if (
|
||||
typeof abcFeedbackId === 'string' ||
|
||||
typeof abcFeedbackId === 'number'
|
||||
) {
|
||||
ids.push(String(abcFeedbackId));
|
||||
}
|
||||
}
|
||||
for (const id of ids) assigneeByFeedbackId.set(id, assignee);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Assignment data is optional; the feedback list must still load.
|
||||
}
|
||||
|
||||
const tickets = visibleFeedbacks
|
||||
.filter(
|
||||
(feedback) =>
|
||||
typeof feedback.id === 'string' ||
|
||||
typeof feedback.id === 'number',
|
||||
)
|
||||
.map((feedback) => {
|
||||
const ticketId = Number(feedback.id);
|
||||
const { title, description } = getAbcFeedbackContent(feedback);
|
||||
const createdAt =
|
||||
typeof feedback.createdAt === 'string' ?
|
||||
feedback.createdAt
|
||||
: new Date().toISOString();
|
||||
const updatedAt =
|
||||
typeof feedback.updatedAt === 'string' ?
|
||||
feedback.updatedAt
|
||||
: createdAt;
|
||||
const legacyRequester = getLegacyRequester(feedback);
|
||||
const requesterLoginId =
|
||||
typeof legacyRequester?.login_id === 'string' ?
|
||||
legacyRequester.login_id
|
||||
: '';
|
||||
const requesterName =
|
||||
typeof legacyRequester?.name === 'string' ?
|
||||
legacyRequester.name
|
||||
: '';
|
||||
const requesterId =
|
||||
getFeedbackString(feedback, 'requester_id') || requesterLoginId;
|
||||
const requesterEmail =
|
||||
getFeedbackString(feedback, 'requester_email') ||
|
||||
requesterLoginId;
|
||||
const requesterDisplayName =
|
||||
getFeedbackString(feedback, 'requester_name') ||
|
||||
requesterName ||
|
||||
(requesterEmail === principal.email ?
|
||||
(principal.name ?? '')
|
||||
: '');
|
||||
const assignee = assigneeByFeedbackId.get(String(ticketId));
|
||||
|
||||
return {
|
||||
ticket_id: ticketId,
|
||||
workspace_code: workspaceCode,
|
||||
workspace_name: workspaceCode,
|
||||
title,
|
||||
description,
|
||||
requester_id: requesterId,
|
||||
requester_tenant_id: getFeedbackString(
|
||||
feedback,
|
||||
'requester_tenant_id',
|
||||
),
|
||||
requester_contact:
|
||||
getFeedbackString(feedback, 'requester_contact') ||
|
||||
(typeof legacyRequester?.phone === 'string' ?
|
||||
legacyRequester.phone
|
||||
: ''),
|
||||
requester_email: requesterEmail,
|
||||
requester_name: requesterDisplayName,
|
||||
requester_department:
|
||||
getFeedbackString(feedback, 'requester_department') ||
|
||||
(typeof legacyRequester?.department === 'string' ?
|
||||
legacyRequester.department
|
||||
: ''),
|
||||
requester_phone_number:
|
||||
getFeedbackString(feedback, 'requester_phone_number') ||
|
||||
getFeedbackString(feedback, 'phone_number'),
|
||||
assignee_id: assignee?.assignee_id ?? null,
|
||||
assignee_tenant_id: assignee?.assignee_tenant_id ?? null,
|
||||
assignee_name: assignee?.assignee_name ?? null,
|
||||
assignee_email: assignee?.assignee_email ?? null,
|
||||
is_secret: getFeedbackFlag(feedback, 'is_secret'),
|
||||
category_code:
|
||||
getFeedbackString(feedback, 'Category') ||
|
||||
getFeedbackString(feedback, 'category') ||
|
||||
'GENERAL',
|
||||
ticket_type: 'GENERAL',
|
||||
requires_approval: false,
|
||||
status_code:
|
||||
getFeedbackString(feedback, 'feedback_status') ||
|
||||
getFeedbackString(feedback, 'status') ||
|
||||
'INIT',
|
||||
feedback_status:
|
||||
getFeedbackString(feedback, 'feedback_status') ||
|
||||
getFeedbackString(feedback, 'status') ||
|
||||
'INIT',
|
||||
approval_status: 'NOT_REQUIRED',
|
||||
sync_status: 'SYNCED',
|
||||
issue_link_status: 'NOT_LINKED',
|
||||
external_issue_status: null,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
extra_fields: {
|
||||
abc_feedback_id: String(ticketId),
|
||||
ip_address:
|
||||
getFeedbackString(feedback, 'IP') ||
|
||||
getFeedbackString(feedback, 'ip_address'),
|
||||
mac_address:
|
||||
getFeedbackString(feedback, 'MAC_address') ||
|
||||
getFeedbackString(feedback, 'mac_address'),
|
||||
priority: getFeedbackString(feedback, 'priority'),
|
||||
feedback_status: getFeedbackString(feedback, 'feedback_status'),
|
||||
requester_email: getFeedbackString(feedback, 'requester_email'),
|
||||
requester_name: getFeedbackString(feedback, 'requester_name'),
|
||||
requester_department: getFeedbackString(
|
||||
feedback,
|
||||
'requester_department',
|
||||
),
|
||||
requester_phone_number: getFeedbackString(
|
||||
feedback,
|
||||
'requester_phone_number',
|
||||
),
|
||||
},
|
||||
activity: [],
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).json(tickets);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message:
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: 'ABC 피드백 목록을 불러오지 못했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (requesterId) query.set('requesterId', requesterId);
|
||||
if (requesterTenantId) query.set('requesterTenantId', requesterTenantId);
|
||||
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/workspaces/${encodeURIComponent(workspaceCode)}/tickets${suffix}`,
|
||||
{ headers: getSupportAuthHeaders(req) },
|
||||
);
|
||||
const data = (await response.json()) as
|
||||
| SupportTicketRecord[]
|
||||
| { message?: string };
|
||||
|
||||
if (response.ok && Array.isArray(data)) {
|
||||
try {
|
||||
const mergedData = await mergeTicketsWithAbcFallback(
|
||||
workspaceCode,
|
||||
requesterId,
|
||||
requesterTenantId,
|
||||
data,
|
||||
);
|
||||
|
||||
return res.status(200).json(mergedData);
|
||||
} catch {
|
||||
return res.status(200).json(data);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
try {
|
||||
const fallbackData = buildFallbackTicketList(
|
||||
workspaceCode,
|
||||
requesterId,
|
||||
requesterTenantId,
|
||||
await listAbcSupportFeedbacks(workspaceCode),
|
||||
);
|
||||
|
||||
return res.status(200).json(fallbackData);
|
||||
} catch {
|
||||
return res.status(200).json(
|
||||
listSupportTickets({
|
||||
workspace_code: workspaceCode,
|
||||
requester_id: requesterId,
|
||||
requester_tenant_id: requesterTenantId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
POST: async (req, res) => {
|
||||
const workspaceCode = req.query.workspaceCode as string;
|
||||
const contentType = req.headers['content-type'] ?? '';
|
||||
|
||||
let isAbcMapped = true;
|
||||
try {
|
||||
getSupportAbcTargetConfig(workspaceCode);
|
||||
} catch {
|
||||
isAbcMapped = false;
|
||||
}
|
||||
|
||||
if (isAbcMapped) {
|
||||
const principal = await getSupportPrincipal(req);
|
||||
if (!principal) {
|
||||
return res.status(401).json({
|
||||
message: '로그인 세션을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const requesterFields = toAbcRequesterFields(principal);
|
||||
|
||||
try {
|
||||
let feedbackId: number;
|
||||
|
||||
if (contentType.startsWith('multipart/form-data')) {
|
||||
const { fields, files } = await parseMultipartBody(req);
|
||||
|
||||
const body = {
|
||||
...flattenMultipartFields(fields),
|
||||
...requesterFields,
|
||||
feedback_status: 'INIT',
|
||||
};
|
||||
if (files.length > 0) {
|
||||
const uploadFiles = await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
buffer: new Uint8Array(await readFile(file.filepath)),
|
||||
filename: file.originalFilename ?? 'attachment',
|
||||
mimetype: file.mimetype ?? 'application/octet-stream',
|
||||
})),
|
||||
);
|
||||
feedbackId = await createAbcSupportFeedbackWithImages(
|
||||
workspaceCode,
|
||||
body,
|
||||
uploadFiles,
|
||||
);
|
||||
} else {
|
||||
feedbackId = await createAbcSupportFeedback(workspaceCode, body);
|
||||
}
|
||||
} else {
|
||||
const body = await parseJsonBody(req);
|
||||
feedbackId = await createAbcSupportFeedback(workspaceCode, {
|
||||
...body,
|
||||
...requesterFields,
|
||||
feedback_status: 'INIT',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
ticket_id: feedbackId,
|
||||
feedback_id: feedbackId,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
message:
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: 'ABC 피드백 저장 요청에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType.startsWith('multipart/form-data')) {
|
||||
try {
|
||||
const { fields, files } = await parseMultipartBody(req);
|
||||
const response = await forwardMultipartTicketCreate(
|
||||
workspaceCode,
|
||||
fields,
|
||||
files,
|
||||
getSupportAuthHeaders(req),
|
||||
);
|
||||
const data = await readJsonOrMessage(response);
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch (error) {
|
||||
return res.status(isUploadSizeError(error) ? 413 : 500).json({
|
||||
message:
|
||||
isUploadSizeError(error) ?
|
||||
'첨부파일은 파일당 최대 30MB, 전체 최대 30MB까지 업로드할 수 있습니다.'
|
||||
: error instanceof Error ?
|
||||
error.message
|
||||
: '첨부파일 업로드 요청에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = await parseJsonBody(req);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supportApiBaseUrl}/api/workspaces/${encodeURIComponent(workspaceCode)}/tickets`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getSupportAuthHeaders(req),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
);
|
||||
const data = await readJsonOrMessage(response);
|
||||
|
||||
return res.status(response.status).json(data);
|
||||
} catch {
|
||||
const createdTicket = createSupportTicketStub({
|
||||
workspace_code: workspaceCode,
|
||||
requester_id: String(requestBody.requester_id ?? ''),
|
||||
requester_tenant_id: String(requestBody.requester_tenant_id ?? ''),
|
||||
requester_contact: String(requestBody.requester_contact ?? ''),
|
||||
title: String(requestBody.title ?? ''),
|
||||
description: String(requestBody.description ?? ''),
|
||||
category_code: String(requestBody.category_code ?? ''),
|
||||
ticket_type: String(requestBody.ticket_type ?? 'GENERAL'),
|
||||
is_secret: Boolean(requestBody.is_secret),
|
||||
requires_approval: Boolean(requestBody.requires_approval),
|
||||
extra_fields:
|
||||
(
|
||||
typeof requestBody.extra_fields === 'object' &&
|
||||
requestBody.extra_fields !== null
|
||||
) ?
|
||||
(requestBody.extra_fields as Record<string, string>)
|
||||
: {},
|
||||
});
|
||||
|
||||
try {
|
||||
const feedbackId = await createAbcSupportFeedback(
|
||||
workspaceCode,
|
||||
requestBody,
|
||||
);
|
||||
const syncedTicket = markSupportTicketSyncedStub(
|
||||
createdTicket.ticket_id,
|
||||
feedbackId,
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
...createdTicket,
|
||||
ticket_id: syncedTicket?.ticket_id ?? createdTicket.ticket_id,
|
||||
status_code: syncedTicket?.status_code ?? createdTicket.status_code,
|
||||
feedback_status:
|
||||
syncedTicket?.feedback_status ?? createdTicket.feedback_status,
|
||||
sync_status: syncedTicket?.sync_status ?? 'SYNCED',
|
||||
message: `ABC feedback ${feedbackId} created via fallback.`,
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
const fallbackMessage =
|
||||
fallbackError instanceof Error ?
|
||||
fallbackError.message
|
||||
: 'Unknown fallback error';
|
||||
|
||||
return res.status(200).json({
|
||||
...createdTicket,
|
||||
message: `ABC fallback skipped: ${fallbackMessage}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { NextPage } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { Button } from '@ufb/react';
|
||||
|
||||
import { Path } from '@/shared';
|
||||
import { useOAuthCallback } from '@/features/auth/sign-in-with-oauth';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const OAuthCallbackPage: NextPage<IProps> = () => {
|
||||
const { status } = useOAuthCallback();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4">
|
||||
{status === 'loading' && (
|
||||
<p className="text-title-h1 animate-bounce">Loading...</p>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<p className="text-title-h1">Error!!!</p>
|
||||
<p className="text-title-h4">Please check the toast.</p>
|
||||
<Button onClick={() => router.push(Path.SIGN_IN)}>Go to home</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthCallbackPage;
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { GetStaticProps } from 'next';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { AnonymousTemplate } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { RequestResetPasswordWithEmail } from '@/features/auth/reset-password-with-email';
|
||||
import { AnonymousLayout } from '@/widgets/anonymous-layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
const ResetPasswordPage: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AnonymousTemplate
|
||||
title={t('v2.auth.reset-password.title')}
|
||||
image="/assets/images/send-reset-password-email.svg"
|
||||
>
|
||||
<RequestResetPasswordWithEmail />
|
||||
</AnonymousTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
ResetPasswordPage.getLayout = (page) => {
|
||||
return <AnonymousLayout>{page}</AnonymousLayout>;
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default ResetPasswordPage;
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GetStaticProps } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button, toast } from '@ufb/react';
|
||||
|
||||
import { AnonymousTemplate, TextInput } from '@/shared';
|
||||
import type { IFetchError, NextPageWithLayout } from '@/shared/types';
|
||||
import { useTenantStore } from '@/entities/tenant';
|
||||
import { useUserStore } from '@/entities/user';
|
||||
import { SignInWithOAuthButton } from '@/features/auth/sign-in-with-oauth';
|
||||
import { AnonymousLayout } from '@/widgets/anonymous-layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
const signInWithEmailSchema = z.object({
|
||||
email: z.email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
type FormType = z.infer<typeof signInWithEmailSchema>;
|
||||
|
||||
const SignInPage: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { tenant, refetchTenant } = useTenantStore();
|
||||
const { signInWithEmail } = useUserStore();
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
|
||||
const { handleSubmit, register, formState, setError } = useForm<FormType>({
|
||||
resolver: zodResolver(signInWithEmailSchema),
|
||||
defaultValues: { email: '', password: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (tenant) return;
|
||||
|
||||
void refetchTenant().catch(() => {
|
||||
// Keep the preview fallback message when the tenant API is unavailable.
|
||||
});
|
||||
}, [tenant, refetchTenant]);
|
||||
|
||||
const onSubmit = async (data: FormType) => {
|
||||
try {
|
||||
setLoginLoading(true);
|
||||
await signInWithEmail(data);
|
||||
toast.success(t('v2.toast.success'));
|
||||
} catch (error) {
|
||||
const { message } = error as IFetchError;
|
||||
setError('email', { message: 'invalid email' });
|
||||
setError('password', { message: 'invalid password' });
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnonymousTemplate
|
||||
title={t('button.sign-in')}
|
||||
image="/assets/images/sign-in.svg"
|
||||
imageSub={
|
||||
<p className="text-title-h3 text-center">
|
||||
Listen to <br />
|
||||
users' voice & improve.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{!tenant && (
|
||||
<div className="rounded-16 border border-[#e4dfd4] bg-[#faf7f0] p-4 text-sm leading-6 text-[#5e5544]">
|
||||
로컬 미리보기에서는 tenant 설정 API(`/api/admin/tenants`)가 없어 로그인 입력칸이 표시되지 않습니다.
|
||||
<br />
|
||||
지원 화면 확인은 `/support/INTRA_BOOK_REQUEST/new`, `/ops`, `/admin/issues` 같은 공개 미리보기 경로를 직접 사용해 주세요.
|
||||
</div>
|
||||
)}
|
||||
{tenant?.useOAuth && <SignInWithOAuthButton />}
|
||||
{tenant?.useOAuth && tenant.useEmail && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="border-neutral-tertiary flex-1 border-b-[1px]" />
|
||||
<span className="text-neutral-tertiary">or With Email</span>
|
||||
<div className="border-neutral-tertiary flex-1 border-b-[1px]" />
|
||||
</div>
|
||||
)}
|
||||
{tenant?.useEmail && (
|
||||
<form id="sign-in" onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
type="email"
|
||||
{...register('email')}
|
||||
error={formState.errors.email?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Password"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
type="password"
|
||||
{...register('password')}
|
||||
error={formState.errors.password?.message}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{tenant?.useEmail && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
size="medium"
|
||||
type="submit"
|
||||
loading={loginLoading}
|
||||
form="sign-in"
|
||||
disabled={!formState.isDirty}
|
||||
>
|
||||
{t('button.sign-in')}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link href="/auth/reset-password" className="text-center underline">
|
||||
{t('link.reset-password.title')}
|
||||
</Link>
|
||||
<Link href="/auth/sign-up" className="text-center underline">
|
||||
{t('button.sign-up')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnonymousTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
SignInPage.getLayout = (page) => {
|
||||
return <AnonymousLayout>{page}</AnonymousLayout>;
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default SignInPage;
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { GetStaticProps } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import { AnonymousTemplate, Path, useOAIMutation } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { SignUpWithEmailForm } from '@/features/auth/sign-up-with-email';
|
||||
import { AnonymousLayout } from '@/widgets/anonymous-layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
const SignUpPage: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate: signUp, isPending } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/auth/signUp/email',
|
||||
queryOptions: {
|
||||
async onSuccess() {
|
||||
await router.push(Path.SIGN_IN);
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AnonymousTemplate
|
||||
title={t('v2.auth.sign-up.title')}
|
||||
image="/assets/images/sign-up.svg"
|
||||
>
|
||||
<SignUpWithEmailForm
|
||||
onSubmit={signUp}
|
||||
loading={isPending}
|
||||
submitText={t('button.sign-up')}
|
||||
/>
|
||||
</AnonymousTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
SignUpPage.getLayout = (page) => {
|
||||
return <AnonymousLayout>{page}</AnonymousLayout>;
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default SignUpPage;
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import { fetchWithAuthRefresh } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
interface CreateResponse {
|
||||
ticket_id?: number;
|
||||
feedback_id?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const isValidIpv4 = (value: string) => {
|
||||
const parts = value.trim().split('.');
|
||||
|
||||
return (
|
||||
parts.length === 4 &&
|
||||
parts.every((part) => {
|
||||
if (!/^\d{1,3}$/.test(part)) return false;
|
||||
const number = Number(part);
|
||||
return number >= 0 && number <= 255;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const FixedIpRequestPage: NextPageWithLayout = () => {
|
||||
const [fixedIpAddress, setFixedIpAddress] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submittedRequestId, setSubmittedRequestId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedIp = fixedIpAddress.trim();
|
||||
|
||||
if (!isValidIpv4(normalizedIp)) {
|
||||
setErrorMessage('IPv4 주소를 올바르게 입력해 주세요. 예: 192.168.0.10');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage('');
|
||||
setSubmittedRequestId(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('title', '고정 IP 신청');
|
||||
formData.append('description', '고정 IP 주소: ' + normalizedIp);
|
||||
formData.append('is_secret', 'false');
|
||||
formData.append('ticket_type', 'GENERAL');
|
||||
formData.append('requires_approval', 'false');
|
||||
formData.append(
|
||||
'extra_fields',
|
||||
JSON.stringify({
|
||||
request_type: 'FIXED_IP_REQUEST',
|
||||
fixed_ip_address: normalizedIp,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await fetchWithAuthRefresh(
|
||||
'/api/support/workspaces/EGBIM/tickets',
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as CreateResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
data.message ?? '고정 IP 신청 등록에 실패했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
const requestId = data.ticket_id ?? data.feedback_id ?? null;
|
||||
setSubmittedRequestId(requestId);
|
||||
setFixedIpAddress('');
|
||||
toast.success('고정 IP 신청이 접수되었습니다.');
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '고정 IP 신청 등록 중 오류가 발생했습니다.';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SupportPortalShell
|
||||
workspaceCode="EGBIM"
|
||||
workspaceName="총무페이지"
|
||||
currentTab="new"
|
||||
eyebrow="Fixed IP Request"
|
||||
title="고정 IP 신청"
|
||||
description="총무페이지로 고정 IP 사용을 신청합니다."
|
||||
hideTabs
|
||||
>
|
||||
<section className="rounded-[12px] border border-[#dcdcdc] bg-white p-6 shadow-[0_4px_16px_rgba(0,0,0,0.04)]">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="rounded-[8px] border border-[#dfdfdf]">
|
||||
<div className="flex flex-col gap-2 border-b border-[#e9e9e9] px-4 py-4">
|
||||
<label
|
||||
htmlFor="fixed-ip-address"
|
||||
className="text-sm font-semibold text-[#333]"
|
||||
>
|
||||
고정 IP 주소<span className="text-[#d93025]">*</span>
|
||||
</label>
|
||||
<p className="text-xs text-[#777]">
|
||||
신청할 고정 IPv4 주소를 입력해 주세요.
|
||||
</p>
|
||||
<input
|
||||
id="fixed-ip-address"
|
||||
name="fixedIpAddress"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={fixedIpAddress}
|
||||
onChange={(event) => setFixedIpAddress(event.target.value)}
|
||||
placeholder="예: 192.168.0.10"
|
||||
required
|
||||
className="h-[42px] w-full rounded-[4px] border border-[#d8d8d8] px-3 text-[14px] text-[#222] outline-none placeholder:text-[#a0a0a0] focus:border-[#888]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-[6px] border border-[#f2c7c7] bg-[#fff4f2] px-4 py-3 text-sm text-[#8d4343]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{submittedRequestId !== null ? (
|
||||
<div className="rounded-[6px] border border-[#c9e4d7] bg-[#f1fbf5] px-4 py-3 text-sm text-[#23613e]">
|
||||
신청이 접수되었습니다. 접수번호: {submittedRequestId}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-[4px] bg-[#0f5f48] px-6 py-3 text-sm font-semibold text-white transition hover:bg-[#0a4f3b] disabled:cursor-not-allowed disabled:bg-[#8aa79d]"
|
||||
>
|
||||
{isSubmitting ? '신청 중...' : '신청'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</SupportPortalShell>
|
||||
);
|
||||
};
|
||||
|
||||
FixedIpRequestPage.getLayout = (page: React.ReactNode) => page;
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({
|
||||
req,
|
||||
res,
|
||||
locale,
|
||||
}) => {
|
||||
if (!req.cookies.jwt) {
|
||||
const callbackUrl = encodeURIComponent('/fixed-ip-request');
|
||||
res.setHeader('Set-Cookie', 'ufb.oauth.callback-url=%2Ffixed-ip-request; Path=/; Max-Age=600; SameSite=Lax');
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination:
|
||||
'/auth/sign-in?callback_url=' + callbackUrl + '&force_login=true',
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default FixedIpRequestPage;
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { GetServerSideProps, NextPage } from 'next';
|
||||
|
||||
import { Path } from '@/shared';
|
||||
|
||||
const IndexPage: NextPage = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async () => {
|
||||
return Promise.resolve({
|
||||
redirect: { destination: Path.SIGN_IN, permanent: true },
|
||||
});
|
||||
};
|
||||
export default IndexPage;
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { AnonymousTemplate } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { ResetPasswordWithEmailForm } from '@/features/auth/reset-password-with-email';
|
||||
import { AnonymousLayout } from '@/widgets/anonymous-layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
interface IProps {
|
||||
code: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const ResetPasswordPage: NextPageWithLayout<IProps> = (props) => {
|
||||
const { code, email } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AnonymousTemplate
|
||||
title={t('v2.link.reset-password.title')}
|
||||
image="/assets/images/reset-password.svg"
|
||||
>
|
||||
<ResetPasswordWithEmailForm code={code} email={email} />
|
||||
</AnonymousTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
ResetPasswordPage.getLayout = (page) => {
|
||||
return <AnonymousLayout>{page}</AnonymousLayout>;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<IProps> = async ({
|
||||
locale,
|
||||
query,
|
||||
}) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
code: (query.code ?? '') as string,
|
||||
email: (query.email ?? '') as string,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default ResetPasswordPage;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { AnonymousTemplate } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { InvitedUserSignupForm } from '@/entities/user';
|
||||
import { AnonymousLayout } from '@/widgets/anonymous-layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
interface IProps {
|
||||
code: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const UserInvitationPage: NextPageWithLayout<IProps> = ({ code, email }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AnonymousTemplate
|
||||
title={t('link.user-invitation.title')}
|
||||
image="/assets/images/reset-password.svg"
|
||||
>
|
||||
<InvitedUserSignupForm code={code} email={email} />
|
||||
</AnonymousTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
UserInvitationPage.getLayout = (page) => {
|
||||
return <AnonymousLayout>{page}</AnonymousLayout>;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<IProps> = async ({
|
||||
locale,
|
||||
query,
|
||||
}) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
code: (query.code ?? '') as string,
|
||||
email: (query.email ?? '') as string,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default UserInvitationPage;
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import type { GetStaticProps } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { Icon } from '@ufb/react';
|
||||
|
||||
import { Path, useAllProjects } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { TenantGuard } from '@/entities/tenant';
|
||||
import { useUserStore } from '@/entities/user';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
const MainIndexPage: NextPageWithLayout = () => {
|
||||
const router = useRouter();
|
||||
const { user } = useUserStore();
|
||||
const { data } = useAllProjects();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !user) return;
|
||||
|
||||
const [project] = data.items;
|
||||
if (user.type === 'SUPER' && data.items.length > 0) {
|
||||
void router.push(Path.OVERVIEW);
|
||||
return;
|
||||
}
|
||||
if (data.items.length > 1) {
|
||||
void router.push(Path.OVERVIEW);
|
||||
return;
|
||||
}
|
||||
if (project) {
|
||||
void router.push({
|
||||
pathname: Path.PROJECT_MAIN,
|
||||
query: { projectId: project.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.type === 'SUPER') {
|
||||
void router.push({ pathname: Path.CREATE_PROJECT });
|
||||
return;
|
||||
}
|
||||
void router.push({ pathname: '/main/profile' });
|
||||
}, [data, router, user]);
|
||||
|
||||
return (
|
||||
<TenantGuard>
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Icon name="RiLoader2Line" className="animate-spin" size={40} />
|
||||
</div>
|
||||
</TenantGuard>
|
||||
);
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default MainIndexPage;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import type { GetStaticProps } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
Icon,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from '@ufb/react';
|
||||
|
||||
import { useAllProjects } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import SideMenuLayout from '@/shared/ui/side-menu-layout.ui';
|
||||
import { TenantGuard } from '@/entities/tenant';
|
||||
import {
|
||||
ChangePasswordSetting,
|
||||
UserProfileSetting,
|
||||
useUserStore,
|
||||
} from '@/entities/user';
|
||||
import { Layout } from '@/widgets/layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
const ProfilePage: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const overlay = useOverlay();
|
||||
const { user } = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [currentMenu] = useQueryState<string>(
|
||||
'menu',
|
||||
parseAsString.withDefault('profile'),
|
||||
);
|
||||
const { data } = useAllProjects();
|
||||
|
||||
const openWarningNoProjects = () => {
|
||||
overlay.open(({ close, isOpen }) => (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent radius="large">
|
||||
<DialogTitle>
|
||||
{t('v2.dialog.no-project-in-profile-page.title')}
|
||||
</DialogTitle>
|
||||
<DialogBody className="flex flex-col items-center gap-2">
|
||||
<Image
|
||||
src="/assets/images/no-projects-in-profile-page.svg"
|
||||
alt=""
|
||||
width={240}
|
||||
height={240}
|
||||
/>
|
||||
<p className="w-full whitespace-pre-line px-4">
|
||||
{t('v2.dialog.no-project-in-profile-page.description', {
|
||||
name: user?.name ?? '--',
|
||||
})}
|
||||
</p>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<DialogClose variant="primary">
|
||||
{t('v2.button.confirm')}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || data.items.length > 0) return;
|
||||
openWarningNoProjects();
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<TenantGuard>
|
||||
<SideMenuLayout
|
||||
sideMenu={
|
||||
<Menu
|
||||
type="single"
|
||||
orientation="vertical"
|
||||
className="w-full p-0"
|
||||
value={currentMenu}
|
||||
onValueChange={(value) =>
|
||||
router.push({ pathname: router.pathname, query: { menu: value } })
|
||||
}
|
||||
>
|
||||
<MenuItem value="profile">
|
||||
<Icon name="RiInformation2Line" />
|
||||
{t('main.profile.profile-info')}
|
||||
</MenuItem>
|
||||
<MenuItem value="change-password">
|
||||
<Icon name="RiUser2Line" />
|
||||
{t('main.profile.change-password')}
|
||||
</MenuItem>
|
||||
{user?.type === 'SUPER' && (
|
||||
<Link
|
||||
href={{
|
||||
pathname: '/main/tenant',
|
||||
query: { menu: 'admin-permission' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="admin-permission">
|
||||
<Icon name="RiAdminLine" />
|
||||
관리자 설정
|
||||
</MenuItem>
|
||||
</Link>
|
||||
)}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
{currentMenu === 'profile' && <UserProfileSetting />}
|
||||
{currentMenu === 'change-password' && <ChangePasswordSetting />}
|
||||
</SideMenuLayout>
|
||||
</TenantGuard>
|
||||
);
|
||||
};
|
||||
|
||||
ProfilePage.getLayout = (page) => {
|
||||
return <Layout title="Profile">{page}</Layout>;
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
export default ProfilePage;
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
AlertButton,
|
||||
AlertContent,
|
||||
AlertDescription,
|
||||
AlertTextContainer,
|
||||
AlertTitle,
|
||||
Icon,
|
||||
} from '@ufb/react';
|
||||
|
||||
import type { NextPageWithLayout } from '@/shared';
|
||||
import {
|
||||
CreateSectionTemplate,
|
||||
CreationLayout,
|
||||
Path,
|
||||
useOAIQuery,
|
||||
} from '@/shared';
|
||||
import { ChannelInfoForm } from '@/entities/channel';
|
||||
import type { ChannelInfo } from '@/entities/channel';
|
||||
import { FieldTable } from '@/entities/field';
|
||||
import { ProjectGuard } from '@/entities/project';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const CompleteChannelCreationPage: NextPageWithLayout<IProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { channelId, projectId } = {
|
||||
channelId: Number(router.query.channelId),
|
||||
projectId: Number(router.query.projectId),
|
||||
};
|
||||
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/channels/{channelId}',
|
||||
variables: { channelId, projectId },
|
||||
});
|
||||
|
||||
const channelInfoFormMethods = useForm<ChannelInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
channelInfoFormMethods.reset(data);
|
||||
}, [data]);
|
||||
|
||||
const gotoFeedback = () =>
|
||||
router.push({ pathname: Path.FEEDBACK, query: { channelId, projectId } });
|
||||
|
||||
return (
|
||||
<CreationLayout
|
||||
title={t('main.create-channel.complete-title')}
|
||||
leftPanel={
|
||||
<p className="text-neutral-secondary text-large-normal whitespace-pre-line">
|
||||
{t('v2.description.create-channel')}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div className="border-neutral-tertiary flex h-[calc(100vh-96px)] w-full flex-col gap-4 overflow-auto rounded border p-6">
|
||||
<h3 className="text-title-h3">{t('v2.text.summary')}</h3>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={[t('channel-setting-menu.channel-info')]}
|
||||
className="overflow-auto"
|
||||
>
|
||||
<CreateSectionTemplate
|
||||
title={t('channel-setting-menu.channel-info')}
|
||||
defaultOpen
|
||||
>
|
||||
<FormProvider {...channelInfoFormMethods}>
|
||||
<ChannelInfoForm readOnly />
|
||||
</FormProvider>
|
||||
</CreateSectionTemplate>
|
||||
<CreateSectionTemplate title={t('channel-setting-menu.field-mgmt')}>
|
||||
<FieldTable fields={data?.fields ?? []} disableFilter />
|
||||
</CreateSectionTemplate>
|
||||
</Accordion>
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
<Alert className="w-[calc(100vw-32px)] max-w-[700px] shadow-md">
|
||||
<AlertContent>
|
||||
<AlertTextContainer>
|
||||
<AlertTitle>
|
||||
{t('main.create-channel.complete-title')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('main.create-channel.alert-description')}
|
||||
</AlertDescription>
|
||||
</AlertTextContainer>
|
||||
<AlertButton
|
||||
onClick={gotoFeedback}
|
||||
variant="primary"
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
<Icon name="RiSparklingFill" />
|
||||
{t('button.start')}
|
||||
</AlertButton>
|
||||
</AlertContent>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</CreationLayout>
|
||||
);
|
||||
};
|
||||
|
||||
CompleteChannelCreationPage.getLayout = (page: React.ReactElement<IProps>) => {
|
||||
return <ProjectGuard projectId={page.props.projectId}>{page}</ProjectGuard>;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<IProps> = async ({
|
||||
locale,
|
||||
query,
|
||||
}) => {
|
||||
const projectId = parseInt(query.projectId as string);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
projectId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default CompleteChannelCreationPage;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { GetServerSideProps } from 'next';
|
||||
|
||||
import type { NextPageWithLayout } from '@/shared';
|
||||
import { ProjectGuard } from '@/entities/project';
|
||||
import { CreateChannel } from '@/features/create-channel';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const CreateChannelPage: NextPageWithLayout<IProps> = () => {
|
||||
return <CreateChannel />;
|
||||
};
|
||||
|
||||
CreateChannelPage.getLayout = (page: React.ReactElement<IProps>) => {
|
||||
return <ProjectGuard projectId={page.props.projectId}>{page}</ProjectGuard>;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({
|
||||
locale,
|
||||
query,
|
||||
}) => {
|
||||
const projectId = parseInt(query.projectId as string);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
projectId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default CreateChannelPage;
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import dayjs from 'dayjs';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxSelectItem,
|
||||
ComboboxTrigger,
|
||||
Icon,
|
||||
} from '@ufb/react';
|
||||
|
||||
import { DateRangePicker, parseAsDateRange } from '@/shared';
|
||||
import type { DateRangeType, NextPageWithLayout } from '@/shared/types';
|
||||
import {
|
||||
FeedbackLineChart,
|
||||
IssueBarChart,
|
||||
IssueFeedbackLineChart,
|
||||
IssueLineChart,
|
||||
IssueRank,
|
||||
} from '@/entities/dashboard';
|
||||
import { ProjectGuard } from '@/entities/project';
|
||||
import { DashboardCardList } from '@/widgets/dashboard-card-list';
|
||||
import { Layout } from '@/widgets/layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: <Trans i18nKey="text.date.yesterday" />,
|
||||
dateRange: {
|
||||
startDate: dayjs().subtract(1, 'days').startOf('day').toDate(),
|
||||
endDate: dayjs().subtract(1, 'days').endOf('day').toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: <Trans i18nKey="text.date.last-days" tOptions={{ day: 7 }} />,
|
||||
dateRange: {
|
||||
startDate: dayjs().subtract(7, 'days').startOf('day').toDate(),
|
||||
endDate: dayjs().subtract(1, 'days').endOf('day').toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: <Trans i18nKey="text.date.last-days" tOptions={{ day: 30 }} />,
|
||||
dateRange: {
|
||||
startDate: dayjs().subtract(30, 'days').startOf('day').toDate(),
|
||||
endDate: dayjs().subtract(1, 'days').endOf('day').toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: <Trans i18nKey="text.date.last-days" tOptions={{ day: 90 }} />,
|
||||
dateRange: {
|
||||
startDate: dayjs().subtract(90, 'days').startOf('day').toDate(),
|
||||
endDate: dayjs().subtract(1, 'days').endOf('day').toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: <Trans i18nKey="text.date.last-days" tOptions={{ day: 180 }} />,
|
||||
dateRange: {
|
||||
startDate: dayjs().subtract(180, 'days').startOf('day').toDate(),
|
||||
endDate: dayjs().subtract(1, 'days').endOf('day').toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: <Trans i18nKey="text.date.last-days" tOptions={{ day: 365 }} />,
|
||||
dateRange: {
|
||||
startDate: dayjs().subtract(365, 'days').startOf('day').toDate(),
|
||||
endDate: dayjs().subtract(1, 'days').endOf('day').toDate(),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DashboardPage: NextPageWithLayout<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
const [dateRange, setDateRange] = useQueryState(
|
||||
'dateRange',
|
||||
parseAsDateRange.withDefault({
|
||||
startDate: dayjs().subtract(1, 'y').startOf('day').toDate(),
|
||||
endDate: dayjs().subtract(1, 'day').endOf('day').toDate(),
|
||||
}),
|
||||
);
|
||||
const [invisibles, setInvisibles] = useState<Record<string, boolean>>({});
|
||||
|
||||
const onChangeDateRange = async (v: DateRangeType) => {
|
||||
if (!v?.startDate || !v.endDate) return;
|
||||
await setDateRange({ startDate: v.startDate, endDate: v.endDate });
|
||||
};
|
||||
|
||||
const viewItems = [
|
||||
{
|
||||
label: t('dashboard-card.total-feedback.title'),
|
||||
value: 'TotalFeedbackCard',
|
||||
},
|
||||
{
|
||||
label: t('dashboard-card.today-feedback.title'),
|
||||
value: 'TodayFeedbackCard',
|
||||
},
|
||||
{
|
||||
label: t('dashboard-card.yesterday-feedback.title'),
|
||||
value: 'YesterdayFeedbackCard',
|
||||
},
|
||||
{
|
||||
label: t('dashboard-card.n-days-feedback.title', { n: 7 }),
|
||||
value: 'SevenDaysFeedbackCard',
|
||||
},
|
||||
{
|
||||
label: t('dashboard-card.n-days-feedback.title', { n: 30 }),
|
||||
value: 'ThirtyDaysFeedbackCard',
|
||||
},
|
||||
{ label: t('dashboard-card.total-issue.title'), value: 'TotalIssueCard' },
|
||||
{
|
||||
label: t('dashboard-card.issue-ratio.title'),
|
||||
value: 'CreateFeedbackPerIssueCard',
|
||||
},
|
||||
{ label: t('dashboard-card.today-issue.title'), value: 'TodayIssueCard' },
|
||||
{
|
||||
label: t('dashboard-card.yesterday-issue.title'),
|
||||
value: 'YesterdayIssueCard',
|
||||
},
|
||||
{
|
||||
label: t('dashboard-card.n-days-issue.title', { n: 7 }),
|
||||
value: 'SevenDaysIssueCard',
|
||||
},
|
||||
{
|
||||
label: t('dashboard-card.n-days-issue.title', { n: 30 }),
|
||||
value: 'ThirtyDaysIssueCard',
|
||||
},
|
||||
];
|
||||
|
||||
const viewCount = useMemo(
|
||||
() =>
|
||||
viewItems.reduce(
|
||||
(acc, { value }) => (invisibles[value] ? acc : acc + 1),
|
||||
0,
|
||||
),
|
||||
[invisibles],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 pb-14">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={onChangeDateRange}
|
||||
maxDate={dayjs().subtract(1, 'day').toDate()}
|
||||
options={options}
|
||||
maxDays={365}
|
||||
tooltipContent={t('tooltip.dashboard-date-picker-button')}
|
||||
/>
|
||||
<Combobox>
|
||||
<ComboboxTrigger>
|
||||
<Icon name="RiEyeLine" />
|
||||
View
|
||||
<Badge variant="subtle">{viewCount}</Badge>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
{viewItems.map(({ label, value }) => (
|
||||
<ComboboxSelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
onSelect={() => {
|
||||
setInvisibles((prev) => ({
|
||||
...prev,
|
||||
[value]: !prev[value],
|
||||
}));
|
||||
}}
|
||||
checked={!invisibles[value]}
|
||||
>
|
||||
{label}
|
||||
</ComboboxSelectItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardCardList
|
||||
projectId={projectId}
|
||||
from={dateRange.startDate}
|
||||
to={dateRange.endDate}
|
||||
invisible={invisibles}
|
||||
/>
|
||||
<FeedbackLineChart
|
||||
from={dateRange.startDate}
|
||||
to={dateRange.endDate}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<IssueLineChart
|
||||
from={dateRange.startDate}
|
||||
to={dateRange.endDate}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<div className="flex items-stretch gap-5">
|
||||
<div className="flex-1">
|
||||
<IssueBarChart
|
||||
from={dateRange.startDate}
|
||||
to={dateRange.endDate}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<IssueRank
|
||||
from={dateRange.startDate}
|
||||
to={dateRange.endDate}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IssueFeedbackLineChart
|
||||
from={dateRange.startDate}
|
||||
to={dateRange.endDate}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DashboardPage.getLayout = (page: React.ReactElement<IProps>) => {
|
||||
return (
|
||||
<Layout
|
||||
projectId={page.props.projectId}
|
||||
title="Dashboard"
|
||||
extra={
|
||||
<span className="text-neutral-tertiary text-small-normal flex items-center gap-1">
|
||||
<Icon name="RiRefreshLine" size={12} />
|
||||
Updated: {page.props.generatedAt}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ProjectGuard projectId={page.props.projectId}>{page}</ProjectGuard>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({
|
||||
query,
|
||||
locale,
|
||||
}) => {
|
||||
const projectId = parseInt(query.projectId as string);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
generatedAt: dayjs().format('YYYY-MM-DD HH:mm'),
|
||||
projectId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
import { Button, Icon } from '@ufb/react';
|
||||
|
||||
import type { TableFilterField } from '@/shared';
|
||||
import { useAllChannels, useOAIQuery } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { useCheckAIUsageLimit } from '@/entities/ai';
|
||||
import type { Channel } from '@/entities/channel';
|
||||
import { useRoutingChannelCreation } from '@/entities/channel/lib';
|
||||
import {
|
||||
ensureFeedbackAssigneeField,
|
||||
ensureFeedbackPriorityField,
|
||||
FeedbackTable,
|
||||
} from '@/entities/feedback';
|
||||
import { ProjectGuard } from '@/entities/project';
|
||||
import { Layout } from '@/widgets/layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
const FeedbackManagementPage: NextPageWithLayout<IProps> = (props) => {
|
||||
const { projectId } = props;
|
||||
const { data: channels, isLoading } = useAllChannels(projectId);
|
||||
const [currentChannelId, setCurrentChannelId] = useQueryState<number>(
|
||||
'channelId',
|
||||
parseAsInteger.withDefault(-1),
|
||||
);
|
||||
|
||||
useCheckAIUsageLimit(projectId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!channels || currentChannelId !== -1) return;
|
||||
void setCurrentChannelId(channels.items[0]?.id ?? null);
|
||||
}, [channels, currentChannelId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Icon name="RiLoader4Line" className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channels?.meta.totalItems === 0 || currentChannelId === -1) {
|
||||
return <EmptyChannel projectId={projectId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedbackContainer
|
||||
projectId={projectId}
|
||||
channelId={currentChannelId}
|
||||
channels={channels?.items ?? []}
|
||||
onChangeChannelId={setCurrentChannelId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyChannel = (props: { projectId: number }) => {
|
||||
const { projectId } = props;
|
||||
const { t } = useTranslation();
|
||||
const { openChannelInProgress } = useRoutingChannelCreation(projectId);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Image
|
||||
src="/assets/images/channel-make.svg"
|
||||
alt="No channels"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
<p className="text-neutral-secondary text-center">
|
||||
{t('v2.text.no-data.channel')}
|
||||
</p>
|
||||
<Button onClick={openChannelInProgress}>
|
||||
{t('v2.text.create-channel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeedbackContainer = (props: {
|
||||
projectId: number;
|
||||
channelId: number;
|
||||
channels: Channel[];
|
||||
onChangeChannelId: (channelId: number) => void;
|
||||
}) => {
|
||||
const { projectId, channelId, channels, onChangeChannelId } = props;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { data: channelData, isLoading } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/channels/{channelId}',
|
||||
variables: { channelId, projectId },
|
||||
queryOptions: {
|
||||
enabled: channelId !== -1,
|
||||
},
|
||||
});
|
||||
|
||||
const fields = useMemo(
|
||||
() =>
|
||||
ensureFeedbackAssigneeField(
|
||||
ensureFeedbackPriorityField([...(channelData?.fields ?? [])]),
|
||||
).sort((a, b) => a.order - b.order),
|
||||
[channelData],
|
||||
);
|
||||
|
||||
const filterFields = useMemo(() => {
|
||||
return fields
|
||||
.filter((field) => field.key !== 'createdAt' && field.format !== 'images')
|
||||
.map((field) => {
|
||||
if (field.format === 'text') {
|
||||
return {
|
||||
key: field.key,
|
||||
name: field.name,
|
||||
format: 'string',
|
||||
matchType: ['CONTAINS'],
|
||||
};
|
||||
}
|
||||
if (field.format === 'keyword') {
|
||||
return {
|
||||
key: field.key,
|
||||
name: field.name,
|
||||
format: 'string',
|
||||
matchType: ['IS'],
|
||||
};
|
||||
}
|
||||
if (field.format === 'number') {
|
||||
return {
|
||||
key: field.key,
|
||||
name: field.name,
|
||||
format: 'number',
|
||||
matchType: ['IS'],
|
||||
};
|
||||
}
|
||||
if (field.format === 'date') {
|
||||
return {
|
||||
key: field.key,
|
||||
name: field.name,
|
||||
format: 'date',
|
||||
matchType: ['BETWEEN', 'IS'],
|
||||
};
|
||||
}
|
||||
|
||||
if (field.key === 'issues') {
|
||||
return {
|
||||
key: 'issueIds',
|
||||
format: 'issue',
|
||||
name: field.name,
|
||||
matchType: ['IS', 'CONTAINS'],
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
if (field.format === 'select') {
|
||||
return {
|
||||
key: field.key,
|
||||
name: field.name,
|
||||
format: field.format,
|
||||
options: field.options,
|
||||
matchType: ['IS'],
|
||||
};
|
||||
}
|
||||
if (field.format === 'multiSelect') {
|
||||
return {
|
||||
key: field.key,
|
||||
name: field.name,
|
||||
format: field.format,
|
||||
options: field.options,
|
||||
matchType: ['CONTAINS', 'IS'],
|
||||
};
|
||||
}
|
||||
if (field.format === 'aiField') {
|
||||
return {
|
||||
key: field.key,
|
||||
name: field.name,
|
||||
format: 'string',
|
||||
matchType: ['CONTAINS'],
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter((v) => !!v?.key) as TableFilterField[];
|
||||
}, [fields]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Icon name="RiLoader4Line" className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!channelData) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Icon name="RiInboxArchiveLine" size={32} />
|
||||
<p className="text-neutral-secondary text-center">
|
||||
Channel is invalid.
|
||||
</p>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { projectId },
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedbackTable
|
||||
channels={channels}
|
||||
currentChannel={channelData}
|
||||
setCurrentChannelId={onChangeChannelId}
|
||||
projectId={projectId}
|
||||
filterFields={filterFields}
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
FeedbackManagementPage.getLayout = (page: React.ReactElement<IProps>) => {
|
||||
return (
|
||||
<Layout projectId={page.props.projectId} title="Feedback">
|
||||
<ProjectGuard projectId={page.props.projectId}>{page}</ProjectGuard>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({
|
||||
query,
|
||||
locale,
|
||||
}) => {
|
||||
const projectId = parseInt(query.projectId as string);
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
projectId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default FeedbackManagementPage;
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { GetServerSideProps } from 'next';
|
||||
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { ProjectGuard } from '@/entities/project';
|
||||
import { IssueTable } from '@/widgets/issue-table';
|
||||
import { Layout } from '@/widgets/layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
const IssueMangementPage: NextPageWithLayout<IProps> = (props) => {
|
||||
const { projectId } = props;
|
||||
return <IssueTable projectId={projectId} />;
|
||||
};
|
||||
|
||||
IssueMangementPage.getLayout = (page: React.ReactElement<IProps>) => {
|
||||
return (
|
||||
<Layout projectId={page.props.projectId} title="Issue" isHeightDynamic>
|
||||
<ProjectGuard projectId={page.props.projectId}>{page}</ProjectGuard>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<IProps> = async ({
|
||||
locale,
|
||||
query,
|
||||
}) => {
|
||||
const projectId = parseInt(query.projectId as string);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
projectId,
|
||||
noChannel: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default IssueMangementPage;
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { GetServerSideProps } from 'next';
|
||||
|
||||
import { Path } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const SettingPage = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<IProps> = async ({
|
||||
query,
|
||||
}) => {
|
||||
return new Promise((resove) =>
|
||||
resove({
|
||||
redirect: {
|
||||
destination: Path.SETTINGS.replace(
|
||||
'[projectId]',
|
||||
String(query.projectId),
|
||||
),
|
||||
permanent: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingPage;
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { fetchWithAuthRefresh } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import SideMenuLayout from '@/shared/ui/side-menu-layout.ui';
|
||||
import { ProjectGuard } from '@/entities/project';
|
||||
import { useUserStore } from '@/entities/user';
|
||||
import {
|
||||
ChannelInfoSetting,
|
||||
FieldSetting,
|
||||
ImageConfigSetting,
|
||||
} from '@/widgets/channel-settings';
|
||||
import { Layout } from '@/widgets/layout';
|
||||
import {
|
||||
ApiKeySetting,
|
||||
GenerativeAiSetting,
|
||||
IssueAdminSetting,
|
||||
IssueTrackerSetting,
|
||||
MemberSetting,
|
||||
ProjectInfoSetting,
|
||||
RoleSetting,
|
||||
WebhookSetting,
|
||||
} from '@/widgets/project-settings';
|
||||
import {
|
||||
AdminPermissionSetting,
|
||||
SettingsMenuList,
|
||||
} from '@/widgets/setting-menu';
|
||||
import type { SettingMenu, SubSettingMenu } from '@/widgets/setting-menu';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const SettingsPage: NextPageWithLayout<IProps> = ({ projectId }) => {
|
||||
const router = useRouter();
|
||||
const { user } = useUserStore();
|
||||
const [isSupportAdmin, setIsSupportAdmin] = useState<boolean | null>(null);
|
||||
|
||||
const currentMenu = (router.query.menu ?? 'project') as SettingMenu;
|
||||
const currentSubMenu = router.query.submenu as SubSettingMenu | undefined;
|
||||
const currentChannelId =
|
||||
router.query.channelId ? Number(router.query.channelId) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setIsSupportAdmin(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void fetchWithAuthRefresh('/api/support/access')
|
||||
.then(async (response) => {
|
||||
const access =
|
||||
response.ok ?
|
||||
((await response.json()) as { is_system_admin?: boolean })
|
||||
: null;
|
||||
if (!cancelled) setIsSupportAdmin(access?.is_system_admin === true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setIsSupportAdmin(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentMenu !== 'admin-permission' || isSupportAdmin !== false) return;
|
||||
void router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { projectId, menu: 'project' },
|
||||
});
|
||||
}, [currentMenu, isSupportAdmin, projectId, router]);
|
||||
|
||||
return (
|
||||
<SideMenuLayout
|
||||
sideMenu={
|
||||
<SettingsMenuList
|
||||
projectId={projectId}
|
||||
settingMenuValue={currentMenu}
|
||||
channelId={currentChannelId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{currentMenu === 'project' && (
|
||||
<ProjectInfoSetting projectId={projectId} />
|
||||
)}
|
||||
{currentMenu === 'issue-admin' && (
|
||||
<IssueAdminSetting projectId={projectId} />
|
||||
)}
|
||||
{currentMenu === 'member' && (
|
||||
<>
|
||||
{currentSubMenu !== 'role' && <MemberSetting projectId={projectId} />}
|
||||
{currentSubMenu === 'role' && <RoleSetting projectId={projectId} />}
|
||||
</>
|
||||
)}
|
||||
{currentMenu === 'api-key' && <ApiKeySetting projectId={projectId} />}
|
||||
{currentMenu === 'issue-tracker' && (
|
||||
<IssueTrackerSetting projectId={projectId} />
|
||||
)}
|
||||
{currentMenu === 'webhook' && <WebhookSetting projectId={projectId} />}
|
||||
{currentMenu === 'generative-ai' && (
|
||||
<GenerativeAiSetting projectId={projectId} />
|
||||
)}
|
||||
{currentMenu === 'admin-permission' && isSupportAdmin === true && (
|
||||
<AdminPermissionSetting />
|
||||
)}
|
||||
{currentChannelId && (
|
||||
<>
|
||||
{currentMenu === 'channel-info' && (
|
||||
<ChannelInfoSetting
|
||||
projectId={projectId}
|
||||
channelId={currentChannelId}
|
||||
/>
|
||||
)}
|
||||
{currentMenu === 'field-mgmt' && !currentSubMenu && (
|
||||
<FieldSetting projectId={projectId} channelId={currentChannelId} />
|
||||
)}
|
||||
{currentMenu === 'image-mgmt' && (
|
||||
<ImageConfigSetting
|
||||
projectId={projectId}
|
||||
channelId={currentChannelId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SideMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
SettingsPage.getLayout = (page: React.ReactElement<IProps>) => {
|
||||
return (
|
||||
<Layout projectId={page.props.projectId} title="Settings">
|
||||
<ProjectGuard projectId={page.props.projectId}>{page}</ProjectGuard>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<IProps> = async ({
|
||||
locale,
|
||||
query,
|
||||
}) => {
|
||||
const projectId = parseInt(query.projectId as string);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
projectId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useEffect } from 'react';
|
||||
import type { GetStaticProps, NextPage } from 'next';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
AlertButton,
|
||||
AlertContent,
|
||||
AlertDescription,
|
||||
AlertTextContainer,
|
||||
AlertTitle,
|
||||
} from '@ufb/react';
|
||||
|
||||
import {
|
||||
CreateSectionTemplate,
|
||||
CreationLayout,
|
||||
Path,
|
||||
useOAIQuery,
|
||||
} from '@/shared';
|
||||
import { ApiKeyTable } from '@/entities/api-key';
|
||||
import { MemberTable } from '@/entities/member';
|
||||
import { useMembmerSearch } from '@/entities/member/lib';
|
||||
import type { ProjectInfo } from '@/entities/project';
|
||||
import { ProjectInfoForm } from '@/entities/project';
|
||||
import { RoleTable } from '@/entities/role';
|
||||
import { useCreateChannelStore } from '@/features/create-channel';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
const CompleteProjectCreationPage: NextPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { reset } = useCreateChannelStore();
|
||||
const projectId = Number(router.query.projectId);
|
||||
|
||||
const { data: project } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}',
|
||||
variables: { projectId },
|
||||
});
|
||||
|
||||
const { data: apiKeys } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/api-keys',
|
||||
variables: { projectId },
|
||||
});
|
||||
const { data: roles } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/roles',
|
||||
variables: { projectId },
|
||||
});
|
||||
const { data: members } = useMembmerSearch(projectId, {});
|
||||
|
||||
const projectInfoFormMethods = useForm<ProjectInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
projectInfoFormMethods.reset(project);
|
||||
}, [project]);
|
||||
|
||||
return (
|
||||
<CreationLayout
|
||||
title={t('main.create-project.complete-title')}
|
||||
leftPanel={
|
||||
<p className="text-neutral-secondary text-large-normal whitespace-pre-line">
|
||||
{t('v2.description.create-project')}
|
||||
</p>
|
||||
}
|
||||
leftBottom={
|
||||
<div className="flex justify-end">
|
||||
<Image
|
||||
src={`/assets/images/create-complete.svg`}
|
||||
alt="Create Project"
|
||||
className="align"
|
||||
width={320}
|
||||
height={320}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="border-neutral-tertiary flex h-[calc(100vh-96px)] w-full flex-col gap-4 overflow-auto rounded border p-6">
|
||||
<h3 className="text-title-h3">{t('v2.text.summary')}</h3>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={[t('v2.project-setting-menu.project-info')]}
|
||||
className="overflow-auto"
|
||||
>
|
||||
<CreateSectionTemplate
|
||||
title={t('v2.project-setting-menu.project-info')}
|
||||
defaultOpen
|
||||
>
|
||||
<FormProvider {...projectInfoFormMethods}>
|
||||
<ProjectInfoForm type="update" readOnly />
|
||||
</FormProvider>
|
||||
</CreateSectionTemplate>
|
||||
<CreateSectionTemplate title={t('v2.project-setting-menu.role-mgmt')}>
|
||||
<RoleTable roles={roles?.roles ?? []} />
|
||||
</CreateSectionTemplate>
|
||||
<CreateSectionTemplate
|
||||
title={t('v2.project-setting-menu.member-mgmt')}
|
||||
>
|
||||
<MemberTable data={members?.items ?? []} />
|
||||
</CreateSectionTemplate>
|
||||
<CreateSectionTemplate
|
||||
title={t('v2.project-setting-menu.api-key-mgmt')}
|
||||
>
|
||||
<ApiKeyTable data={apiKeys?.items ?? []} />
|
||||
</CreateSectionTemplate>
|
||||
</Accordion>
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
<Alert className="w-[calc(100vw-32px)] max-w-[700px] shadow-md">
|
||||
<AlertContent>
|
||||
<AlertTextContainer>
|
||||
<AlertTitle>
|
||||
{t('main.create-project.complete-title')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('main.create-project.alert-description')}
|
||||
</AlertDescription>
|
||||
</AlertTextContainer>
|
||||
<AlertButton
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push({
|
||||
pathname: Path.DASHBOARD,
|
||||
query: { projectId },
|
||||
})
|
||||
}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{t('button.next-time')}
|
||||
</AlertButton>
|
||||
<AlertButton
|
||||
onClick={async () => {
|
||||
reset();
|
||||
await router.push({
|
||||
pathname: Path.CREATE_CHANNEL,
|
||||
query: { projectId },
|
||||
});
|
||||
}}
|
||||
variant="primary"
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{t('main.setting.button.create-channel')}
|
||||
</AlertButton>
|
||||
</AlertContent>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</CreationLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default CompleteProjectCreationPage;
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import type { GetStaticProps, NextPage } from 'next';
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
|
||||
import { NoProjectDialogInProjectCreation, useAllProjects } from '@/shared';
|
||||
import { CreateProject } from '@/features/create-project';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
const CreateProjectPage: NextPage = () => {
|
||||
const overlay = useOverlay();
|
||||
|
||||
const { data } = useAllProjects();
|
||||
const openWarningNoProjects = () => {
|
||||
overlay.open(({ close, isOpen }) => (
|
||||
<NoProjectDialogInProjectCreation isOpen={isOpen} close={close} />
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || data.items.length > 0) return;
|
||||
openWarningNoProjects();
|
||||
}, [data]);
|
||||
|
||||
return <CreateProject />;
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default CreateProjectPage;
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import type { GetStaticProps } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import { Icon, Menu, MenuItem } from '@ufb/react';
|
||||
|
||||
import { Path } from '@/shared';
|
||||
import type {NextPageWithLayout} from '@/shared';
|
||||
import SideMenuLayout from '@/shared/ui/side-menu-layout.ui';
|
||||
import { useUserStore } from '@/entities/user';
|
||||
import { Layout } from '@/widgets/layout';
|
||||
import {
|
||||
AdminPermissionSetting,
|
||||
LoginSetting,
|
||||
TenantInfoSetting,
|
||||
UserManagementSetting,
|
||||
} from '@/widgets/setting-menu';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
const TenantPage: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { user } = useUserStore();
|
||||
const [currentMenu] = useQueryState<string>(
|
||||
'menu',
|
||||
parseAsString.withDefault('tenant'),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.type !== 'SUPER') {
|
||||
void router.replace(Path.MAIN);
|
||||
}
|
||||
}, [router, user]);
|
||||
|
||||
if (!user || user.type !== 'SUPER') {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Icon name="RiLoader2Line" className="animate-spin" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SideMenuLayout
|
||||
sideMenu={
|
||||
<Menu
|
||||
type="single"
|
||||
orientation="vertical"
|
||||
className="w-full p-0"
|
||||
value={currentMenu}
|
||||
onValueChange={(value) =>
|
||||
router.push({ pathname: router.pathname, query: { menu: value } })
|
||||
}
|
||||
>
|
||||
<MenuItem value="tenant">
|
||||
<Icon name="RiInformation2Line" />
|
||||
{t('tenant-setting-menu.tenant-info')}
|
||||
</MenuItem>
|
||||
<MenuItem value="login">
|
||||
<Icon name="RiUser2Line" />
|
||||
{t('tenant-setting-menu.sign-up-mgmt')}
|
||||
</MenuItem>
|
||||
<MenuItem value="user">
|
||||
<Icon name="RiShieldKeyholeLine" />
|
||||
{t('tenant-setting-menu.user-mgmt')}
|
||||
</MenuItem>
|
||||
<MenuItem value="admin-permission">
|
||||
<Icon name="RiAdminLine" />
|
||||
관리자 권한 설정
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
{currentMenu === 'tenant' && <TenantInfoSetting />}
|
||||
{currentMenu === 'login' && <LoginSetting />}
|
||||
{currentMenu === 'user' && <UserManagementSetting />}
|
||||
{currentMenu === 'admin-permission' && <AdminPermissionSetting />}
|
||||
</SideMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
TenantPage.getLayout = (page) => {
|
||||
return <Layout title="Tenant">{page}</Layout>;
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default TenantPage;
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
|
||||
import SupportOperationsShell from '@/features/support-portal/ui/support-operations-shell.ui';
|
||||
import SupportStatusBadge from '@/features/support-portal/ui/support-status-badge.ui';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import type { SupportTicketRecord } from '@/server/support-stub';
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
import { AnonymousLayout } from '@/widgets/anonymous-layout';
|
||||
|
||||
const OpsPage: NextPageWithLayout = () => {
|
||||
const [tickets, setTickets] = useState<SupportTicketRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [workingId, setWorkingId] = useState<number | null>(null);
|
||||
|
||||
const loadTickets = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/support/tickets');
|
||||
const data = (await response.json()) as SupportTicketRecord[] | { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('message' in data && data.message ? data.message : '승인 큐를 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
setTickets(data as SupportTicketRecord[]);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '승인 큐 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadTickets();
|
||||
}, []);
|
||||
|
||||
const pendingTickets = useMemo(
|
||||
() => tickets.filter((ticket) => ticket.requires_approval && ticket.approval_status === 'PENDING'),
|
||||
[tickets],
|
||||
);
|
||||
const approvedTickets = useMemo(
|
||||
() => tickets.filter((ticket) => ticket.approval_status === 'APPROVED'),
|
||||
[tickets],
|
||||
);
|
||||
|
||||
const handleApprove = async (ticketId: number) => {
|
||||
setWorkingId(ticketId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/support/tickets/${ticketId}/approve`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = (await response.json()) as { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message ?? '승인 처리에 실패했습니다.');
|
||||
}
|
||||
|
||||
await loadTickets();
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '승인 처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setWorkingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SupportOperationsShell
|
||||
currentTab="ops"
|
||||
eyebrow="Ops Console"
|
||||
title="운영 승인 큐에서 내부 승인 흐름을 정리하는 화면"
|
||||
description={[
|
||||
'승인형 요청만 추려서 운영자가 빠르게 확인하고 승인할 수 있게 구성했습니다.',
|
||||
'승인 후에는 같은 스텁 저장소에서 관리자 이슈 연결 페이지로 곧바로 이어집니다.',
|
||||
].join('\n')}
|
||||
aside={
|
||||
<div className="rounded-[24px] bg-[#f5efe2] p-4 text-[#594f39]">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-[#8a7c5c]">Queue state</p>
|
||||
<p className="mt-2 text-sm leading-6">
|
||||
pending={pendingTickets.length}
|
||||
<br />
|
||||
approved={approvedTickets.length}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.25fr)_360px]">
|
||||
<section className="rounded-[32px] border border-white/80 bg-white/82 p-7 shadow-[0_18px_50px_rgba(43,63,50,0.08)]">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-[#6f8374]">Approval queue</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.02em] text-[#1d3324]">승인 대기 요청</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadTickets()}
|
||||
className="rounded-full border border-[#ccd8ca] bg-white px-4 py-2 text-sm font-medium text-[#284133] transition hover:bg-[#f7faf5]"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="mt-6 text-sm text-[#506055]">승인 큐를 불러오는 중입니다...</p>}
|
||||
|
||||
{!isLoading && errorMessage && (
|
||||
<div className="mt-6 rounded-[24px] border border-[#f2c7c7] bg-[#fff4f2] p-4 text-sm text-[#8d4343]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !errorMessage && pendingTickets.length === 0 && (
|
||||
<div className="mt-6 rounded-[24px] bg-[#f3f5ef] p-8 text-sm text-[#526556]">
|
||||
현재 승인 대기 중인 요청이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid gap-4">
|
||||
{pendingTickets.map((ticket) => (
|
||||
<article
|
||||
key={ticket.ticket_id}
|
||||
className="rounded-[28px] border border-[#dde7dc] bg-[linear-gradient(135deg,_rgba(255,255,255,0.96),_rgba(245,250,244,0.92))] p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#6f8374]">
|
||||
<span>{ticket.workspace_name}</span>
|
||||
<span>•</span>
|
||||
<span>Ticket {ticket.ticket_id}</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold tracking-[-0.02em] text-[#1f3526]">{ticket.title}</h3>
|
||||
<p className="text-sm leading-7 text-[#546759]">{ticket.description}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<SupportStatusBadge label="status" value={ticket.status_code} />
|
||||
<SupportStatusBadge label="approval" value={ticket.approval_status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
<SupportStatusBadge label="sync" value={ticket.sync_status} />
|
||||
<span className="rounded-full bg-[#f0eadc] px-3 py-1 text-xs font-medium text-[#6d5a39]">
|
||||
requester {ticket.requester_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-4 rounded-[24px] bg-[#f6faf5] px-4 py-4">
|
||||
<p className="text-sm text-[#516456]">
|
||||
승인 시 운영 큐 상태가 정리되고, 관리자 페이지에서 이슈 생성이 가능해집니다.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={workingId === ticket.ticket_id}
|
||||
onClick={() => void handleApprove(ticket.ticket_id)}
|
||||
className="rounded-full bg-[#173629] px-5 py-2 text-sm font-medium text-white transition hover:bg-[#214937] disabled:cursor-not-allowed disabled:bg-[#9aaea1]"
|
||||
>
|
||||
{workingId === ticket.ticket_id ? '승인 처리 중...' : '승인'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="flex flex-col gap-5">
|
||||
<section className="rounded-[28px] border border-[#dce6da] bg-white/78 p-6 shadow-[0_18px_45px_rgba(43,63,50,0.08)]">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-[#6d8272]">Approved backlog</p>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{approvedTickets.length === 0 ? (
|
||||
<p className="rounded-[22px] bg-[#eef4eb] px-4 py-4 text-sm text-[#516456]">
|
||||
아직 승인 완료 항목이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
approvedTickets.map((ticket) => (
|
||||
<div key={ticket.ticket_id} className="rounded-[22px] bg-[#eef4eb] px-4 py-4 text-sm text-[#2d4a39]">
|
||||
<p className="font-medium">{ticket.title}</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-[#6b816e]">
|
||||
Ticket {ticket.ticket_id}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</SupportOperationsShell>
|
||||
);
|
||||
};
|
||||
|
||||
OpsPage.getLayout = (page: React.ReactNode) => {
|
||||
return <AnonymousLayout>{page}</AnonymousLayout>;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default OpsPage;
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { Button, Icon } from '@ufb/react';
|
||||
|
||||
import { Path, useOAIQuery } from '@/shared';
|
||||
import { isProjectAccessCodeMatch } from '@/shared/lib/project-access-code';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { useUserStore } from '@/entities/user';
|
||||
import { AnonymousLayout } from '@/widgets/anonymous-layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
interface IProps {
|
||||
projectCode: string;
|
||||
}
|
||||
|
||||
const ProjectAccessPage: NextPageWithLayout<IProps> = ({ projectCode }) => {
|
||||
const router = useRouter();
|
||||
const { user } = useUserStore();
|
||||
const [hasSession, setHasSession] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
setHasSession(
|
||||
document.cookie
|
||||
.split(';')
|
||||
.some((cookie) => cookie.trim().startsWith('jwt=')),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const { data, isLoading } = useOAIQuery({
|
||||
path: '/api/admin/projects',
|
||||
variables: { limit: 1000 },
|
||||
queryOptions: {
|
||||
enabled: hasSession || !!user,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const hasJwtCookie = document.cookie
|
||||
.split(';')
|
||||
.some((cookie) => cookie.trim().startsWith('jwt='));
|
||||
|
||||
if (hasJwtCookie || user) {
|
||||
setHasSession(true);
|
||||
return;
|
||||
}
|
||||
|
||||
void router.replace({
|
||||
pathname: Path.SIGN_IN,
|
||||
query: { callback_url: `/project-access/${projectCode}` },
|
||||
});
|
||||
}, [projectCode, router, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !data) return;
|
||||
|
||||
const targetProject = data.items.find((project) =>
|
||||
isProjectAccessCodeMatch(project.name, projectCode),
|
||||
);
|
||||
|
||||
if (!targetProject) {
|
||||
void router.replace('/403');
|
||||
return;
|
||||
}
|
||||
|
||||
void router.replace({
|
||||
pathname: Path.FEEDBACK,
|
||||
query: { projectId: targetProject.id },
|
||||
});
|
||||
}, [data, projectCode, router, user]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-6 py-12">
|
||||
<div className="flex max-w-lg flex-col items-center gap-4 text-center">
|
||||
<Icon name="RiLoader4Line" className="animate-spin" size={32} />
|
||||
<h1 className="text-title-h3">프로젝트 페이지로 이동 중입니다.</h1>
|
||||
<p className="text-small-normal text-neutral-secondary">
|
||||
BARON-SSO 로그인 이후 권한이 확인되면 해당 프로젝트 피드백 목록으로
|
||||
이동합니다.
|
||||
</p>
|
||||
{!isLoading && user && (
|
||||
<Button variant="outline" onClick={() => router.replace('/403')}>
|
||||
접근 가능한 프로젝트가 아니면 여기서 중단
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectAccessPage.getLayout = (page) => {
|
||||
return <AnonymousLayout>{page}</AnonymousLayout>;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({
|
||||
locale,
|
||||
params,
|
||||
}) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
projectCode: String(params?.projectCode ?? ''),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default ProjectAccessPage;
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GetStaticProps } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@ufb/react';
|
||||
|
||||
import { Path, useOAIQuery } from '@/shared';
|
||||
import { getProjectAccessCode } from '@/shared/lib/project-access-code';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { useUserStore } from '@/entities/user';
|
||||
import { AnonymousLayout } from '@/widgets/anonymous-layout';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
const ProjectAccessIndexPage: NextPageWithLayout = () => {
|
||||
const { user } = useUserStore();
|
||||
const [hasSession, setHasSession] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
setHasSession(
|
||||
document.cookie
|
||||
.split(';')
|
||||
.some((cookie) => cookie.trim().startsWith('jwt=')),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const { data, isLoading } = useOAIQuery({
|
||||
path: '/api/admin/projects',
|
||||
variables: { limit: 1000 },
|
||||
queryOptions: {
|
||||
enabled: hasSession || !!user,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-primary flex min-h-screen items-center justify-center px-6 py-12">
|
||||
<div className="rounded-24 border-neutral-tertiary bg-background flex w-full max-w-3xl flex-col gap-6 border p-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-title-h2">프로젝트 이동 테스트</h1>
|
||||
<p className="text-small-normal text-neutral-secondary">
|
||||
아래 버튼은 프로젝트별 진입 링크입니다. 로그인 전에는 BARON-SSO로
|
||||
이동하고, 로그인 후에는 허용된 프로젝트의 피드백 목록으로
|
||||
연결됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!hasSession && !user && (
|
||||
<div className="rounded-16 border-neutral-tertiary flex flex-col gap-4 border p-6">
|
||||
<p className="text-small-normal text-neutral-secondary">
|
||||
프로젝트 버튼 목록은 로그인 후 표시됩니다. 먼저 BARON-SSO로
|
||||
로그인한 뒤 이 페이지로 다시 돌아오게 연결해 두었습니다.
|
||||
</p>
|
||||
<Link
|
||||
href={{
|
||||
pathname: Path.SIGN_IN,
|
||||
query: { callback_url: '/project-access' },
|
||||
}}
|
||||
>
|
||||
<Button className="w-full">BARON-SSO 로그인</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(hasSession || user) && isLoading && (
|
||||
<p className="text-small-normal">Loading...</p>
|
||||
)}
|
||||
|
||||
{(hasSession || user) &&
|
||||
!isLoading &&
|
||||
(data?.items.length ?? 0) === 0 && (
|
||||
<div className="rounded-16 border-neutral-tertiary text-small-normal text-neutral-tertiary border border-dashed p-6">
|
||||
접근 가능한 프로젝트가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{(data?.items ?? []).map((project) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/project-access/${getProjectAccessCode(project.name)}`}
|
||||
>
|
||||
<Button className="w-full justify-start" variant="outline">
|
||||
{project.name}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectAccessIndexPage.getLayout = (page) => {
|
||||
return <AnonymousLayout>{page}</AnonymousLayout>;
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default ProjectAccessIndexPage;
|
||||
@@ -0,0 +1,925 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import {
|
||||
CommentImageGallery,
|
||||
fetchWithAuthRefresh,
|
||||
} from '@/shared';
|
||||
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 SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
import type { SupportTicketRecord } from '@/server/support-types';
|
||||
|
||||
interface SupportTicketCommentRecord {
|
||||
comment_id: number;
|
||||
ticket_id: number;
|
||||
author_id: string;
|
||||
author_tenant_id: string;
|
||||
author_name: string;
|
||||
content: string;
|
||||
is_internal: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
edited_at: string | null;
|
||||
attachments?: SupportAttachmentRecord[];
|
||||
can_edit?: boolean;
|
||||
can_delete?: boolean;
|
||||
}
|
||||
|
||||
interface SupportAttachmentRecord {
|
||||
attachment_id: number;
|
||||
original_file_name: string;
|
||||
mime_type?: string | null;
|
||||
file_size?: number | null;
|
||||
created_at: string;
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
const formatAttachmentSize = (fileSize?: number | null) => {
|
||||
if (!fileSize || fileSize <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (fileSize >= 1024 * 1024) {
|
||||
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
return `${Math.max(1, Math.round(fileSize / 1024))} KB`;
|
||||
};
|
||||
|
||||
const issueStatusLabelMap: Record<string, string> = {
|
||||
INIT: '신규',
|
||||
ON_REVIEW: '검토중',
|
||||
RECEIVED: '접수됨',
|
||||
PENDING_APPROVAL: '승인 대기',
|
||||
APPROVED: '승인 완료',
|
||||
IN_PROGRESS: '진행중',
|
||||
PENDING: '대기중',
|
||||
RESOLVED: '해결됨',
|
||||
CLOSED: '종료됨',
|
||||
REJECTED: '반려됨',
|
||||
NOT_LINKED: '미연동',
|
||||
NOT_REQUIRED: '대상 아님',
|
||||
};
|
||||
|
||||
const resolveIssueStatusLabel = (ticket: SupportTicketRecord) => {
|
||||
if (ticket.issue_link_status !== 'LINKED') {
|
||||
return (
|
||||
issueStatusLabelMap[ticket.issue_link_status] ?? ticket.issue_link_status
|
||||
);
|
||||
}
|
||||
|
||||
return issueStatusLabelMap[ticket.status_code] ?? ticket.status_code;
|
||||
};
|
||||
|
||||
const SupportDetailPage: NextPageWithLayout = () => {
|
||||
const router = useRouter();
|
||||
const workspaceCode =
|
||||
typeof router.query.workspaceCode === 'string' ?
|
||||
router.query.workspaceCode
|
||||
: '';
|
||||
const ticketId =
|
||||
typeof router.query.ticketId === 'string' ? router.query.ticketId : '';
|
||||
|
||||
const encodedWorkspaceCode = encodeURIComponent(workspaceCode);
|
||||
const [ticket, setTicket] = useState<SupportTicketRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
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 [comments, setComments] = useState<SupportTicketCommentRecord[]>([]);
|
||||
const [commentDraft, setCommentDraft] = useState('');
|
||||
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
||||
const [editingCommentDraft, setEditingCommentDraft] = useState('');
|
||||
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
||||
const [isCommentSaving, setIsCommentSaving] = useState(false);
|
||||
const [commentAttachments, setCommentAttachments] = useState<File[]>([]);
|
||||
const commentFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchComments = async (
|
||||
nextTicketId: string,
|
||||
nextAbcFeedbackId?: string,
|
||||
) => {
|
||||
setIsCommentsLoading(true);
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({ workspaceCode });
|
||||
if (nextAbcFeedbackId) query.set('abcFeedbackId', nextAbcFeedbackId);
|
||||
const response = await fetchWithAuthRefresh(
|
||||
`/api/support/tickets/${nextTicketId}/comments?${query.toString()}`,
|
||||
);
|
||||
const data = (await response.json()) as
|
||||
| SupportTicketCommentRecord[]
|
||||
| { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
'message' in data && data.message ?
|
||||
data.message
|
||||
: '댓글을 불러오지 못했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
setComments(data as SupportTicketCommentRecord[]);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '댓글 조회 중 오류가 발생했습니다.';
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticketId) return;
|
||||
|
||||
const fetchDetail = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuthRefresh(
|
||||
`/api/support/tickets/${ticketId}?workspaceCode=${encodeURIComponent(workspaceCode)}`,
|
||||
);
|
||||
const data = (await response.json()) as
|
||||
| SupportTicketRecord
|
||||
| { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
'message' in data && data.message ?
|
||||
data.message
|
||||
: '상세 정보를 불러오지 못했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '상세 조회 중 오류가 발생했습니다.',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchDetail();
|
||||
}, [ticketId, workspaceCode]);
|
||||
|
||||
const handleCommentAttachmentChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const selectedFiles = Array.from(event.target.files ?? []);
|
||||
setCommentAttachments((current) =>
|
||||
[...current, ...selectedFiles].slice(0, 10),
|
||||
);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleCreateComment = async () => {
|
||||
if (!ticketId || isCommentSaving) return;
|
||||
|
||||
const nextContent = commentDraft.trim();
|
||||
|
||||
if (!nextContent && commentAttachments.length === 0) {
|
||||
toast.error('댓글 내용이나 첨부파일을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCommentSaving(true);
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
workspaceCode,
|
||||
});
|
||||
const abcFeedbackId = ticket?.extra_fields?.abc_feedback_id;
|
||||
if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId);
|
||||
const hasAttachments = commentAttachments.length > 0;
|
||||
const response =
|
||||
hasAttachments ?
|
||||
await fetchWithAuthRefresh(
|
||||
`/api/support/tickets/${ticketId}/comments/attachments?${query.toString()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: (() => {
|
||||
const formData = new FormData();
|
||||
formData.append('content', nextContent);
|
||||
formData.append('is_internal', 'false');
|
||||
commentAttachments.forEach((file) =>
|
||||
formData.append('attachments', file, file.name),
|
||||
);
|
||||
return formData;
|
||||
})(),
|
||||
},
|
||||
)
|
||||
: await fetchWithAuthRefresh(
|
||||
`/api/support/tickets/${ticketId}/comments?${query.toString()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requester_id: ticket?.requester_id ?? '',
|
||||
requester_tenant_id: ticket?.requester_tenant_id ?? '',
|
||||
author_name: 'General User',
|
||||
content: nextContent,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as
|
||||
| SupportTicketCommentRecord
|
||||
| { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
'message' in data && data.message ?
|
||||
data.message
|
||||
: '댓글 저장에 실패했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
setComments((current) => [
|
||||
...current,
|
||||
data as SupportTicketCommentRecord,
|
||||
]);
|
||||
setCommentDraft('');
|
||||
setCommentAttachments([]);
|
||||
toast.success('댓글이 저장되었습니다.');
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '댓글 저장 중 오류가 발생했습니다.';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsCommentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateComment = async (commentId: number) => {
|
||||
const nextContent = editingCommentDraft.trim();
|
||||
|
||||
if (!nextContent) {
|
||||
toast.error('댓글 내용을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCommentSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuthRefresh(
|
||||
`/api/support/tickets/${ticketId}/comments/${commentId}?${new URLSearchParams({
|
||||
workspaceCode,
|
||||
...(ticket?.extra_fields?.abc_feedback_id ? {
|
||||
abcFeedbackId: ticket.extra_fields.abc_feedback_id,
|
||||
} : {}),
|
||||
}).toString()}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requester_id: ticket?.requester_id ?? '',
|
||||
requester_tenant_id: ticket?.requester_tenant_id ?? '',
|
||||
content: nextContent,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as
|
||||
| SupportTicketCommentRecord
|
||||
| { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
'message' in data && data.message ?
|
||||
data.message
|
||||
: '댓글 수정에 실패했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
setComments((current) =>
|
||||
current.map((comment) =>
|
||||
comment.comment_id === commentId ?
|
||||
(data as SupportTicketCommentRecord)
|
||||
: comment,
|
||||
),
|
||||
);
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft('');
|
||||
toast.success('댓글이 수정되었습니다.');
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '댓글 수정 중 오류가 발생했습니다.';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsCommentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId: number) => {
|
||||
if (!ticketId || isCommentSaving) return;
|
||||
|
||||
const confirmed = window.confirm('이 댓글을 삭제하시겠습니까?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCommentSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuthRefresh(
|
||||
`/api/support/tickets/${ticketId}/comments/${commentId}?${new URLSearchParams({
|
||||
workspaceCode,
|
||||
...(ticket?.extra_fields?.abc_feedback_id ? {
|
||||
abcFeedbackId: ticket.extra_fields.abc_feedback_id,
|
||||
} : {}),
|
||||
requesterId: ticket?.requester_id ?? '',
|
||||
requesterTenantId: ticket?.requester_tenant_id ?? '',
|
||||
}).toString()}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json()) as { message?: string };
|
||||
throw new Error(data.message ?? '댓글 삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
setComments((current) =>
|
||||
current.filter((comment) => comment.comment_id !== commentId),
|
||||
);
|
||||
if (editingCommentId === commentId) {
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft('');
|
||||
}
|
||||
toast.success('댓글이 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '댓글 삭제 중 오류가 발생했습니다.';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsCommentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!ticket) return;
|
||||
|
||||
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() ?? '',
|
||||
};
|
||||
|
||||
if (!nextTitle || !nextDescription) {
|
||||
toast.error('제목과 내용을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuthRefresh(
|
||||
`/api/support/tickets/${ticket.ticket_id}?workspaceCode=${encodeURIComponent(workspaceCode)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: nextTitle,
|
||||
description: nextDescription,
|
||||
extra_fields: nextExtraFields,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const data = (await response.json()) as
|
||||
| SupportTicketRecord
|
||||
| { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
'message' in data && data.message ?
|
||||
data.message
|
||||
: '수정에 실패했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTicket = data as SupportTicketRecord;
|
||||
setTicket(updatedTicket);
|
||||
setDraftTitle(updatedTicket.title);
|
||||
setDraftDescription(updatedTicket.description);
|
||||
setDraftExtraFields(updatedTicket.extra_fields);
|
||||
setIsEditing(false);
|
||||
toast.success('문의가 수정되었습니다.');
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '수정 중 오류가 발생했습니다.';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!ticket || isDeleting) return;
|
||||
|
||||
const confirmed = window.confirm('이 문의를 삭제하시겠습니까?');
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuthRefresh(
|
||||
`/api/support/tickets/${ticket.ticket_id}?workspaceCode=${encodeURIComponent(workspaceCode)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json()) as { message?: string };
|
||||
throw new Error(data.message ?? '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success('문의가 삭제되었습니다.');
|
||||
await router.push(
|
||||
`/support/${encodedWorkspaceCode}/list`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.';
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SupportPortalShell
|
||||
workspaceCode={workspaceCode || 'Q&A_Platform'}
|
||||
workspaceName={ticket?.workspace_name ?? '문의 상세'}
|
||||
currentTab="detail"
|
||||
eyebrow="Q&A Detail"
|
||||
title="문의하기(Q&A)"
|
||||
description=""
|
||||
hideHeader
|
||||
>
|
||||
{isLoading && (
|
||||
<section className="rounded-[12px] border border-[#dcdcdc] bg-white p-6 text-sm text-[#666] shadow-[0_4px_16px_rgba(0,0,0,0.04)]">
|
||||
상세 정보를 불러오는 중입니다...
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isLoading && errorMessage && (
|
||||
<section className="rounded-[12px] border border-[#f2c7c7] bg-[#fff4f2] p-6 text-sm text-[#8d4343] shadow-[0_4px_16px_rgba(0,0,0,0.04)]">
|
||||
{errorMessage}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isLoading && ticket && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<section className="rounded-[12px] border border-[#dcdcdc] bg-white p-6 shadow-[0_4px_16px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-[#ececec] pb-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="rounded border border-[#e0e0e0] bg-[#fafafa] px-2 py-1 text-xs text-[#666]">
|
||||
{getSupportCategoryLabel(ticket.category_code)}
|
||||
</span>
|
||||
{ticket.is_secret ?
|
||||
<span className="rounded border border-[#ead7a5] bg-[#fff8df] px-2 py-1 text-xs text-[#856404]">
|
||||
🔒 비밀글
|
||||
</span>
|
||||
: null}
|
||||
{isEditing ?
|
||||
<input
|
||||
value={draftTitle}
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
className="h-[42px] min-w-[280px] rounded-[4px] border border-[#d8d8d8] px-3 text-[20px] font-bold text-[#111] outline-none focus:border-[#888]"
|
||||
/>
|
||||
: <h2 className="text-[20px] font-bold text-[#111]">
|
||||
{ticket.title}
|
||||
</h2>
|
||||
}
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-[#666]">
|
||||
{formatSupportTimestamp(ticket.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-[#dce7f8] px-3 py-1 text-xs font-medium text-[#325c93]">
|
||||
<span className="tracking-[0.01em]">이슈 상태</span>
|
||||
<span>{resolveIssueStatusLabel(ticket)}</span>
|
||||
</span>
|
||||
</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-2 text-sm text-[#555] sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="font-medium text-[#333]">이름: </span>
|
||||
{ticket.requester_name ?? '미등록'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-[#333]">이메일: </span>
|
||||
{ticket.requester_email ?? '미등록'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-[#333]">전화번호: </span>
|
||||
{ticket.requester_phone_number ?? ticket.requester_contact}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-[#333]">부서: </span>
|
||||
{ticket.requester_department ?? '미등록'}
|
||||
</div>
|
||||
</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
|
||||
value={draftDescription}
|
||||
onChange={(event) => setDraftDescription(event.target.value)}
|
||||
rows={12}
|
||||
className="w-full rounded-[4px] border border-[#d8d8d8] px-4 py-3 text-[15px] leading-8 text-[#333] outline-none focus:border-[#888]"
|
||||
/>
|
||||
: <div className="whitespace-pre-line text-[15px] leading-9 text-[#333]">
|
||||
{ticket.description}
|
||||
</div>
|
||||
}
|
||||
</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 ?
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{ticket.attachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.attachment_id}
|
||||
href={
|
||||
attachment.download_url ??
|
||||
`/api/support/tickets/${ticket.ticket_id}/attachments/${attachment.attachment_id}?workspaceCode=${encodeURIComponent(workspaceCode)}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-between rounded-[8px] border border-[#ececec] bg-[#fafafa] px-4 py-3 text-sm text-[#333] hover:bg-[#f4f4f4]"
|
||||
>
|
||||
<span>{attachment.original_file_name}</span>
|
||||
<span className="text-xs text-[#777]">
|
||||
{formatAttachmentSize(attachment.file_size)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
: <p className="mt-3 text-sm text-[#777]">
|
||||
업로드된 첨부파일이 없습니다.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
{isEditing ?
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
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]"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
className="rounded-[4px] bg-[#0f5f48] px-5 py-3 text-sm font-semibold text-white transition hover:bg-[#0a4f3b] disabled:cursor-not-allowed disabled:bg-[#8aa79d]"
|
||||
>
|
||||
{isSaving ? '저장중...' : '저장'}
|
||||
</button>
|
||||
</>
|
||||
: <>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="rounded-[4px] border border-[#d0d0d0] bg-white px-5 py-3 text-sm font-medium text-[#333] transition hover:bg-[#f7f7f7]"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isDeleting}
|
||||
className="rounded-[4px] border border-[#d94b3d] bg-white px-5 py-3 text-sm font-medium text-[#d94b3d] transition hover:bg-[#fff4f2] disabled:cursor-not-allowed disabled:border-[#efb1aa] disabled:text-[#efb1aa]"
|
||||
>
|
||||
{isDeleting ? '삭제중...' : '삭제'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
<Link
|
||||
href={`/support/${encodedWorkspaceCode}/list`}
|
||||
className="rounded-[4px] bg-[#222] px-5 py-3 text-sm font-medium text-white transition hover:bg-[#111]"
|
||||
>
|
||||
목록
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[12px] border border-[#dcdcdc] bg-white p-6 shadow-[0_4px_16px_rgba(0,0,0,0.04)]">
|
||||
<p className="border-b border-[#ececec] pb-3 text-sm font-semibold text-[#222]">
|
||||
댓글({comments.length})
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{isCommentsLoading && (
|
||||
<p className="text-sm text-[#666]">
|
||||
댓글을 불러오는 중입니다...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isCommentsLoading && comments.length === 0 && (
|
||||
<div className="rounded-[8px] border border-[#ececec] bg-[#fafafa] p-4 text-sm text-[#666]">
|
||||
아직 등록된 댓글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCommentsLoading &&
|
||||
comments.map((comment) => {
|
||||
const canEditComment = Boolean(comment.can_edit);
|
||||
const canDeleteComment = Boolean(comment.can_delete);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comment.comment_id}
|
||||
className="rounded-[8px] border border-[#ececec] bg-[#fafafa] p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#222]">
|
||||
{comment.author_name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#777]">
|
||||
{formatSupportTimestamp(comment.created_at)}
|
||||
{comment.edited_at ? ' · 수정됨' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{(canEditComment || canDeleteComment) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{canEditComment && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingCommentId(comment.comment_id);
|
||||
setEditingCommentDraft(comment.content);
|
||||
}}
|
||||
className="text-xs text-[#555] hover:underline"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
)}
|
||||
{canDeleteComment && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleDeleteComment(comment.comment_id)
|
||||
}
|
||||
className="text-xs text-[#d94b3d] hover:underline"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingCommentId === comment.comment_id ?
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<textarea
|
||||
rows={4}
|
||||
value={editingCommentDraft}
|
||||
onChange={(event) =>
|
||||
setEditingCommentDraft(event.target.value)
|
||||
}
|
||||
className="w-full rounded-[4px] border border-[#d8d8d8] bg-white px-3 py-3 text-sm text-[#222] outline-none focus:border-[#888]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft('');
|
||||
}}
|
||||
className="rounded-[4px] border border-[#d0d0d0] bg-white px-4 py-2 text-xs font-medium text-[#333]"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleUpdateComment(comment.comment_id)
|
||||
}
|
||||
disabled={isCommentSaving}
|
||||
className="rounded-[4px] bg-[#222] px-4 py-2 text-xs font-medium text-white disabled:cursor-not-allowed disabled:bg-[#999]"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
: <>
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[#333]">
|
||||
{comment.content}
|
||||
</p>
|
||||
<CommentImageGallery
|
||||
urls={(comment.attachments ?? []).map(
|
||||
(attachment) =>
|
||||
`/api/support/tickets/${ticketId}/attachments/${attachment.attachment_id}?workspaceCode=${encodeURIComponent(workspaceCode)}`,
|
||||
)}
|
||||
names={(comment.attachments ?? []).map(
|
||||
(attachment) => attachment.original_file_name,
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 rounded-[8px] border border-[#ececec] bg-[#fafafa] p-4">
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="댓글을 입력하세요"
|
||||
value={commentDraft}
|
||||
onChange={(event) => setCommentDraft(event.target.value)}
|
||||
className="w-full rounded-[4px] border border-[#d8d8d8] bg-white px-3 py-3 text-sm text-[#222] outline-none placeholder:text-[#999] focus:border-[#888]"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={commentFileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleCommentAttachmentChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commentFileInputRef.current?.click()}
|
||||
className="rounded-[4px] bg-[#075ca8] px-4 py-2 text-sm font-medium text-white hover:bg-[#064e8d]"
|
||||
>
|
||||
📎 파일 첨부
|
||||
</button>
|
||||
<span className="text-xs text-[#777]">
|
||||
{commentAttachments.length > 0 ?
|
||||
`${commentAttachments.length}개 선택됨`
|
||||
: '선택된 파일 없음'}
|
||||
</span>
|
||||
{commentAttachments.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommentAttachments([])}
|
||||
className="text-xs text-[#d94b3d] hover:underline"
|
||||
>
|
||||
전체 삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateComment()}
|
||||
disabled={isCommentSaving}
|
||||
className="rounded-[4px] bg-[#222] px-5 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:bg-[#999]"
|
||||
>
|
||||
{isCommentSaving ? '저장중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</SupportPortalShell>
|
||||
);
|
||||
};
|
||||
|
||||
SupportDetailPage.getLayout = (page: React.ReactNode) => {
|
||||
return page;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default SupportDetailPage;
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { GetServerSideProps } from 'next';
|
||||
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = ({ params }) => {
|
||||
const workspaceCode = typeof params?.workspaceCode === 'string' ? params.workspaceCode : 'INTRA_BOOK_REQUEST';
|
||||
|
||||
return Promise.resolve({
|
||||
redirect: {
|
||||
destination: `/support/${workspaceCode}/list`,
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const SupportWorkspaceIndexPage = () => null;
|
||||
|
||||
export default SupportWorkspaceIndexPage;
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { fetchWithAuthRefresh } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { formatSupportTimestamp } from '@/features/support-portal/lib/format-support-timestamp';
|
||||
import {
|
||||
getSupportCategoryLabel,
|
||||
SUPPORT_CATEGORY_OPTIONS,
|
||||
} from '@/features/support-portal/lib/support-category';
|
||||
import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui';
|
||||
import SupportStatusBadge from '@/features/support-portal/ui/support-status-badge.ui';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
import type {
|
||||
SupportTicketRecord,
|
||||
WorkspaceSummary,
|
||||
} from '@/server/support-types';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
type SortKey =
|
||||
| 'ticket_id'
|
||||
| 'category'
|
||||
| 'requester'
|
||||
| 'title'
|
||||
| 'created_at'
|
||||
| 'status';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const SupportListPage: NextPageWithLayout = () => {
|
||||
const router = useRouter();
|
||||
const workspaceCode =
|
||||
typeof router.query.workspaceCode === 'string' ?
|
||||
router.query.workspaceCode
|
||||
: '';
|
||||
const encodedWorkspaceCode = encodeURIComponent(workspaceCode);
|
||||
|
||||
const [workspaceName, setWorkspaceName] = useState('문의 게시판');
|
||||
const [tickets, setTickets] = useState<SupportTicketRecord[]>([]);
|
||||
const [currentUserProfile, setCurrentUserProfile] = useState<{
|
||||
user_id?: string;
|
||||
tenant_id?: string;
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
}>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [mineOnly, setMineOnly] = useState(false);
|
||||
const [categoryFilter, setCategoryFilter] = useState('ALL');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<SortKey>('created_at');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceCode) return;
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const [workspacesResponse, ticketsResponse, accessResponse] =
|
||||
await Promise.all([
|
||||
fetchWithAuthRefresh('/api/support/workspaces'),
|
||||
fetchWithAuthRefresh(
|
||||
`/api/support/workspaces/${encodedWorkspaceCode}/tickets`,
|
||||
),
|
||||
fetchWithAuthRefresh('/api/support/access'),
|
||||
]);
|
||||
|
||||
const workspaces =
|
||||
(await workspacesResponse.json()) as WorkspaceSummary[];
|
||||
const ticketList = (await ticketsResponse.json()) as
|
||||
| SupportTicketRecord[]
|
||||
| { message?: string };
|
||||
const access =
|
||||
accessResponse.ok ?
|
||||
((await accessResponse.json()) as {
|
||||
user_id?: string;
|
||||
tenant_id?: string;
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
})
|
||||
: {};
|
||||
|
||||
if (!ticketsResponse.ok) {
|
||||
throw new Error(
|
||||
'message' in ticketList && ticketList.message ?
|
||||
ticketList.message
|
||||
: '목록을 불러오지 못했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
const workspace = workspaces.find(
|
||||
(item) => item.workspace_code === workspaceCode,
|
||||
);
|
||||
setWorkspaceName(workspace?.workspace_name ?? workspaceCode);
|
||||
setCurrentUserProfile(access);
|
||||
setTickets(ticketList as SupportTicketRecord[]);
|
||||
setCurrentPage(1);
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '목록 조회 중 오류가 발생했습니다.',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchData();
|
||||
}, [workspaceCode]);
|
||||
|
||||
const isCurrentUserTicket = (ticket: SupportTicketRecord) =>
|
||||
ticket.requester_id === currentUserProfile.user_id &&
|
||||
ticket.requester_tenant_id === currentUserProfile.tenant_id;
|
||||
|
||||
const getRequesterLabel = (ticket: SupportTicketRecord) => {
|
||||
const isCurrentUser = isCurrentUserTicket(ticket);
|
||||
|
||||
const requesterCandidates = [
|
||||
ticket.requester_name,
|
||||
ticket.requester_email,
|
||||
isCurrentUser ? currentUserProfile.name : undefined,
|
||||
isCurrentUser ? currentUserProfile.email : undefined,
|
||||
];
|
||||
|
||||
return (
|
||||
requesterCandidates
|
||||
.find((value) => typeof value === 'string' && value.trim() !== '')
|
||||
?.trim() ?? '미등록 사용자'
|
||||
);
|
||||
};
|
||||
|
||||
const filteredTickets = useMemo(() => {
|
||||
const keyword = searchKeyword.trim().toLowerCase();
|
||||
|
||||
return tickets.filter((ticket) => {
|
||||
if (mineOnly && !isCurrentUserTicket(ticket)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedCategory = String(ticket.category_code)
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const categoryMatches =
|
||||
categoryFilter === 'ALL' ||
|
||||
normalizedCategory === categoryFilter ||
|
||||
(categoryFilter === 'GENERAL_QNA' && normalizedCategory === 'GENERAL');
|
||||
|
||||
if (!categoryMatches) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requesterName =
|
||||
ticket.requester_name.trim() !== '' ?
|
||||
ticket.requester_name.trim()
|
||||
: isCurrentUserTicket(ticket) ? currentUserProfile.name : '';
|
||||
|
||||
return [ticket.title, ticket.description, requesterName]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
});
|
||||
}, [categoryFilter, currentUserProfile, mineOnly, searchKeyword, tickets]);
|
||||
|
||||
const totalCount = filteredTickets.length;
|
||||
|
||||
const sortedTickets = useMemo(() => {
|
||||
const getValue = (ticket: SupportTicketRecord) => {
|
||||
switch (sortKey) {
|
||||
case 'ticket_id':
|
||||
return ticket.ticket_id;
|
||||
case 'category':
|
||||
return getSupportCategoryLabel(ticket.category_code);
|
||||
case 'requester':
|
||||
return getRequesterLabel(ticket);
|
||||
case 'title':
|
||||
return ticket.title;
|
||||
case 'status':
|
||||
return ticket.status_code;
|
||||
case 'created_at':
|
||||
default:
|
||||
return Date.parse(ticket.created_at) || 0;
|
||||
}
|
||||
};
|
||||
|
||||
return [...filteredTickets].sort((left, right) => {
|
||||
const leftValue = getValue(left);
|
||||
const rightValue = getValue(right);
|
||||
const comparison =
|
||||
typeof leftValue === 'number' && typeof rightValue === 'number' ?
|
||||
leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue), 'ko', {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [filteredTickets, sortDirection, sortKey, currentUserProfile]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sortedTickets.length / PAGE_SIZE));
|
||||
const paginatedTickets = sortedTickets.slice(
|
||||
(currentPage - 1) * PAGE_SIZE,
|
||||
currentPage * PAGE_SIZE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage((current) => Math.min(current, totalPages));
|
||||
}, [totalPages]);
|
||||
|
||||
const toggleSort = (nextKey: SortKey) => {
|
||||
if (sortKey === nextKey) {
|
||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(nextKey);
|
||||
setSortDirection(nextKey === 'created_at' ? 'desc' : 'asc');
|
||||
}
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const sortIndicator = (key: SortKey) => {
|
||||
if (sortKey !== key) return '↕';
|
||||
return sortDirection === 'asc' ? '↑' : '↓';
|
||||
};
|
||||
|
||||
return (
|
||||
<SupportPortalShell
|
||||
workspaceCode={workspaceCode || 'Q&A_Platform'}
|
||||
workspaceName={workspaceName}
|
||||
currentTab="list"
|
||||
title="Q&A"
|
||||
hideTabs
|
||||
titleOnly
|
||||
>
|
||||
<section className="rounded-[12px] border border-[#dcdcdc] bg-white p-6 shadow-[0_4px_16px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-[#e6e6e6] pb-4">
|
||||
<span className="text-sm text-[#666]">총 {totalCount}건</span>
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<input
|
||||
value={searchKeyword}
|
||||
onChange={(event) => {
|
||||
setSearchKeyword(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="제목·내용·작성자 검색"
|
||||
aria-label="제목, 내용, 작성자 검색"
|
||||
className="h-[40px] w-[240px] rounded-[4px] border border-[#d8d8d8] px-3 text-sm text-[#222] outline-none placeholder:text-[#999] focus:border-[#888]"
|
||||
/>
|
||||
<label className="inline-flex h-[40px] cursor-pointer items-center gap-2 rounded-[4px] border border-[#d8d8d8] px-3 text-sm text-[#333]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mineOnly}
|
||||
onChange={(event) => {
|
||||
setMineOnly(event.target.checked);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="h-4 w-4 accent-[#222]"
|
||||
/>
|
||||
내가 작성한 글
|
||||
</label>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(event) => {
|
||||
setCategoryFilter(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
aria-label="문의 구분 필터"
|
||||
className="h-[40px] rounded-[4px] border border-[#d8d8d8] bg-white px-3 text-sm text-[#333] outline-none focus:border-[#888]"
|
||||
>
|
||||
<option value="ALL">전체 구분</option>
|
||||
{SUPPORT_CATEGORY_OPTIONS.map((option) => (
|
||||
<option key={option.code} value={option.code}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
{isLoading && (
|
||||
<p className="text-sm text-[#666]">목록을 불러오는 중입니다...</p>
|
||||
)}
|
||||
|
||||
{!isLoading && errorMessage && (
|
||||
<div className="rounded-[8px] border border-[#f2c7c7] bg-[#fff4f2] p-4 text-sm text-[#8d4343]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !errorMessage && filteredTickets.length === 0 && (
|
||||
<div className="rounded-[8px] bg-[#f8f8f8] p-8 text-sm text-[#666]">
|
||||
조회 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !errorMessage && filteredTickets.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-[8px] border border-[#e6e6e6]">
|
||||
<table className="min-w-full divide-y divide-[#e6e6e6] text-sm">
|
||||
<thead className="bg-[#fafafa] text-[#333]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort('ticket_id')}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
번호 <span>{sortIndicator('ticket_id')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort('category')}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
구분 <span>{sortIndicator('category')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort('requester')}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
작성자 <span>{sortIndicator('requester')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort('title')}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
제목 <span>{sortIndicator('title')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort('created_at')}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
등록일 <span>{sortIndicator('created_at')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort('status')}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
상태 <span>{sortIndicator('status')}</span>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#efefef] bg-white text-[#333]">
|
||||
{paginatedTickets.map((ticket) => (
|
||||
<tr
|
||||
key={ticket.ticket_id}
|
||||
className="cursor-pointer hover:bg-[#fcfcfc]"
|
||||
onClick={() =>
|
||||
void router.push(
|
||||
`/support/${encodedWorkspaceCode}/${ticket.ticket_id}`,
|
||||
)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
void router.push(
|
||||
`/support/${encodedWorkspaceCode}/${ticket.ticket_id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="link"
|
||||
>
|
||||
<td className="px-4 py-4 align-top">
|
||||
{ticket.ticket_id}
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top">
|
||||
{getSupportCategoryLabel(ticket.category_code)}
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-4 align-top"
|
||||
title={ticket.requester_id}
|
||||
>
|
||||
{getRequesterLabel(ticket)}
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<Link
|
||||
href={`/support/${encodedWorkspaceCode}/${ticket.ticket_id}`}
|
||||
className="font-medium text-[#222] hover:underline"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{ticket.is_secret ?
|
||||
`🔒 ${ticket.title}`
|
||||
: ticket.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top text-[#666]">
|
||||
{formatSupportTimestamp(ticket.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<SupportStatusBadge
|
||||
label="status"
|
||||
value={ticket.status_code}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !errorMessage && (
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||
{filteredTickets.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-[#666]">
|
||||
<span>
|
||||
{currentPage} / {totalPages} 페이지
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded border border-[#d8d8d8] px-3 py-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
처음
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCurrentPage((current) => Math.max(1, current - 1))
|
||||
}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded border border-[#d8d8d8] px-3 py-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCurrentPage((current) =>
|
||||
Math.min(totalPages, current + 1),
|
||||
)
|
||||
}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded border border-[#d8d8d8] px-3 py-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded border border-[#d8d8d8] px-3 py-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
마지막
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`/support/${encodedWorkspaceCode}/new`}
|
||||
className="rounded-[4px] bg-[#222] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#111]"
|
||||
>
|
||||
작성페이지로 이동
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</SupportPortalShell>
|
||||
);
|
||||
};
|
||||
|
||||
SupportListPage.getLayout = (page: React.ReactNode) => {
|
||||
return page;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default SupportListPage;
|
||||
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import {
|
||||
DescriptionTooltip,
|
||||
fetchWithAuthRefresh,
|
||||
} from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui';
|
||||
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
import type { WorkspaceFormTemplateResponse } from '@/server/support-types';
|
||||
|
||||
interface TicketCreateResponse {
|
||||
ticket_id: number;
|
||||
workspace_code: string;
|
||||
status_code: string;
|
||||
feedback_status: string;
|
||||
sync_status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AttachmentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
sizeLabel: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024;
|
||||
const MAX_ATTACHMENT_COUNT = 10;
|
||||
|
||||
const minimalFieldCopy: Record<string, { label: string; placeholder: string }> =
|
||||
{
|
||||
category: {
|
||||
label: '구분',
|
||||
placeholder: '문의 구분을 선택해 주세요.',
|
||||
},
|
||||
title: {
|
||||
label: '제목',
|
||||
placeholder: '문의 제목을 입력해 주세요.',
|
||||
},
|
||||
description: {
|
||||
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) => {
|
||||
return (
|
||||
minimalFieldCopy[fieldCode] ?? {
|
||||
label: fallbackLabel,
|
||||
placeholder: `${fallbackLabel} 내용을 입력해 주세요.`,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const allowedFieldCodes = [
|
||||
'title',
|
||||
'description',
|
||||
'category',
|
||||
'ip_address',
|
||||
'mac_address',
|
||||
];
|
||||
|
||||
const fieldHelp: Record<string, string> = {
|
||||
ip_address:
|
||||
'Windows: 명령 프롬프트에서 ipconfig를 실행한 뒤 IPv4 주소를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > TCP/IP에서 확인하세요.',
|
||||
mac_address:
|
||||
'Windows: 명령 프롬프트에서 ipconfig /all을 실행한 뒤 Physical Address를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > 하드웨어에서 확인하세요.',
|
||||
};
|
||||
|
||||
const TEST_PROJECT_NAME = 'Q&A_Platform';
|
||||
|
||||
const SupportNewPage: NextPageWithLayout = () => {
|
||||
const router = useRouter();
|
||||
const workspaceCode =
|
||||
typeof router.query.workspaceCode === 'string' ?
|
||||
router.query.workspaceCode
|
||||
: '';
|
||||
|
||||
const [template, setTemplate] =
|
||||
useState<WorkspaceFormTemplateResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [submitMessage, setSubmitMessage] = useState('');
|
||||
const [createdTicketId, setCreatedTicketId] = useState<number | null>(null);
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
||||
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [isSecret, setIsSecret] = useState(false);
|
||||
|
||||
const encodedWorkspaceCode = encodeURIComponent(workspaceCode);
|
||||
|
||||
const visibleFields =
|
||||
template?.fields.filter((field) =>
|
||||
allowedFieldCodes.includes(field.field_code),
|
||||
) ?? [];
|
||||
const categoryField = visibleFields.find(
|
||||
(field) => field.field_code === 'category',
|
||||
);
|
||||
const otherVisibleFields = visibleFields.filter(
|
||||
(field) => field.field_code !== 'category',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceCode) return;
|
||||
|
||||
const fetchTemplate = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuthRefresh(
|
||||
`/api/support/workspaces/${encodedWorkspaceCode}/form-template`,
|
||||
);
|
||||
const data = (await response.json()) as
|
||||
| WorkspaceFormTemplateResponse
|
||||
| { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
'message' in data && data.message ?
|
||||
data.message
|
||||
: '폼 템플릿을 불러오지 못했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
const loadedTemplate = data as WorkspaceFormTemplateResponse;
|
||||
setTemplate(loadedTemplate);
|
||||
setFieldValues(
|
||||
loadedTemplate.fields.reduce<Record<string, string>>(
|
||||
(accumulator, field) => {
|
||||
accumulator[field.field_code] = '';
|
||||
return accumulator;
|
||||
},
|
||||
{},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '폼 템플릿 조회 중 오류가 발생했습니다.',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchTemplate();
|
||||
}, [workspaceCode]);
|
||||
|
||||
const updateFieldValue = (fieldCode: string, value: string) => {
|
||||
setFieldValues((current) => ({ ...current, [fieldCode]: value }));
|
||||
};
|
||||
|
||||
const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (attachments.length + files.length > MAX_ATTACHMENT_COUNT) {
|
||||
toast.error(
|
||||
'첨부파일은 최대 ' +
|
||||
MAX_ATTACHMENT_COUNT +
|
||||
'개까지 선택할 수 있습니다.',
|
||||
);
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const oversizedFile = files.find(
|
||||
(file) => file.size > MAX_ATTACHMENT_BYTES,
|
||||
);
|
||||
if (oversizedFile) {
|
||||
toast.error(
|
||||
"'" +
|
||||
oversizedFile.name +
|
||||
"' 파일이 30MB를 초과합니다. 파일을 줄여서 다시 선택해 주세요.",
|
||||
);
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSize = attachments.reduce(
|
||||
(total, attachment) => total + attachment.file.size,
|
||||
0,
|
||||
);
|
||||
const selectedSize = files.reduce((total, file) => total + file.size, 0);
|
||||
if (currentSize + selectedSize > MAX_ATTACHMENT_BYTES) {
|
||||
toast.error('첨부파일 전체 용량은 30MB까지 업로드할 수 있습니다.');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setAttachments((current) => [
|
||||
...current,
|
||||
...files.map((file, index) => ({
|
||||
id: `${file.name}-${file.size}-${index}`,
|
||||
name: file.name,
|
||||
sizeLabel: `${Math.max(1, Math.round(file.size / 1024))} KB`,
|
||||
file,
|
||||
})),
|
||||
]);
|
||||
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
setAttachments((current) => current.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const title = fieldValues.title?.trim();
|
||||
const description = fieldValues.description?.trim();
|
||||
|
||||
if (!title || !description) {
|
||||
toast.error('제목과 내용은 필수입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitMessage('');
|
||||
setCreatedTicketId(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('requester_contact', '');
|
||||
formData.append('title', title);
|
||||
formData.append('description', description);
|
||||
formData.append('category_code', fieldValues.category ?? '');
|
||||
formData.append('is_secret', String(isSecret));
|
||||
formData.append(
|
||||
'ticket_type',
|
||||
template?.requires_approval ? 'APPROVAL' : 'GENERAL',
|
||||
);
|
||||
formData.append(
|
||||
'requires_approval',
|
||||
String(template?.requires_approval ?? false),
|
||||
);
|
||||
formData.append('extra_fields', JSON.stringify(fieldValues));
|
||||
|
||||
for (const attachment of attachments) {
|
||||
formData.append('attachments', attachment.file, attachment.file.name);
|
||||
}
|
||||
|
||||
const response = await fetchWithAuthRefresh(
|
||||
`/api/support/workspaces/${encodedWorkspaceCode}/tickets`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
const data = (await response.json()) as
|
||||
| TicketCreateResponse
|
||||
| { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
'message' in data && data.message ?
|
||||
data.message
|
||||
: '티켓 생성 요청에 실패했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
const created = data as TicketCreateResponse;
|
||||
setCreatedTicketId(created.ticket_id);
|
||||
setSubmitMessage(
|
||||
`ticket_id=${created.ticket_id}, status=${created.status_code}, sync=${created.sync_status}`,
|
||||
);
|
||||
toast.success('지원 요청이 생성되었습니다.');
|
||||
await router.push(
|
||||
`/support/${encodedWorkspaceCode}/list`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ?
|
||||
error.message
|
||||
: '티켓 생성 중 오류가 발생했습니다.';
|
||||
setSubmitMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SupportPortalShell
|
||||
workspaceCode={workspaceCode || 'Q&A_Platform'}
|
||||
workspaceName={TEST_PROJECT_NAME}
|
||||
currentTab="new"
|
||||
eyebrow="Q&A Write"
|
||||
title="문의등록"
|
||||
hideTabs
|
||||
>
|
||||
<section className="rounded-[12px] border border-[#dcdcdc] bg-white p-6 shadow-[0_4px_16px_rgba(0,0,0,0.04)]">
|
||||
{isLoading && (
|
||||
<p className="text-sm text-[#506055]">
|
||||
폼 템플릿을 불러오는 중입니다...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && errorMessage && (
|
||||
<div className="rounded-[8px] border border-[#f2c7c7] bg-[#fff4f2] p-4 text-sm text-[#8d4343]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && template && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="overflow-hidden rounded-[8px] border border-[#dfdfdf]">
|
||||
<table className="min-w-full text-sm">
|
||||
<tbody className="divide-y divide-[#e9e9e9]">
|
||||
{categoryField ? (
|
||||
<tr key={categoryField.field_code}>
|
||||
<th className="w-[140px] bg-[#fafafa] px-4 py-4 text-left align-top font-semibold text-[#333]">
|
||||
{getFieldCopy(categoryField.field_code, categoryField.label).label}
|
||||
{categoryField.required ? (
|
||||
<span className="text-[#d93025]">*</span>
|
||||
) : null}
|
||||
</th>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={fieldValues[categoryField.field_code] ?? ''}
|
||||
onChange={(event) =>
|
||||
updateFieldValue(
|
||||
categoryField.field_code,
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
required={categoryField.required}
|
||||
className="h-[40px] w-full rounded-[4px] border border-[#d8d8d8] bg-white px-3 text-[14px] text-[#222] outline-none focus:border-[#888]"
|
||||
>
|
||||
<option value="">선택해 주세요.</option>
|
||||
{(categoryField.options ?? []).map((option) => (
|
||||
<option key={option.id} value={option.key}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr>
|
||||
<th className="w-[140px] bg-[#fafafa] px-4 py-4 text-left align-top font-semibold text-[#333]">
|
||||
공개 설정
|
||||
</th>
|
||||
<td className="px-4 py-3">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-[#333]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSecret}
|
||||
onChange={(event) =>
|
||||
setIsSecret(event.target.checked)
|
||||
}
|
||||
className="h-4 w-4 accent-[#0f5f48]"
|
||||
/>
|
||||
비밀글로 작성
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-[#777]">
|
||||
작성자와 권한 있는 관리자만 내용을 확인할 수 있습니다.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
{otherVisibleFields.map((field) => {
|
||||
const copy = getFieldCopy(field.field_code, field.label);
|
||||
const isTextarea = field.field_type === 'textarea';
|
||||
const isSelect = field.field_type === 'select';
|
||||
|
||||
return (
|
||||
<tr key={field.field_code}>
|
||||
<th className="w-[140px] bg-[#fafafa] px-4 py-4 text-left align-top font-semibold text-[#333]">
|
||||
{copy.label}
|
||||
{field.required ?
|
||||
<span className="text-[#d93025]">*</span>
|
||||
: null}
|
||||
</th>
|
||||
<td className="px-4 py-3">
|
||||
{isTextarea ?
|
||||
<textarea
|
||||
value={fieldValues[field.field_code] ?? ''}
|
||||
onChange={(event) =>
|
||||
updateFieldValue(
|
||||
field.field_code,
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
rows={field.field_code === 'description' ? 14 : 5}
|
||||
required={field.required}
|
||||
placeholder={copy.placeholder}
|
||||
className="min-h-[220px] w-full rounded-[4px] border border-[#d8d8d8] px-4 py-3 text-[14px] text-[#222] outline-none placeholder:text-[#a0a0a0] focus:border-[#888]"
|
||||
/>
|
||||
: isSelect ?
|
||||
<select
|
||||
value={fieldValues[field.field_code] ?? ''}
|
||||
onChange={(event) =>
|
||||
updateFieldValue(
|
||||
field.field_code,
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
required={field.required}
|
||||
className="h-[40px] w-full rounded-[4px] border border-[#d8d8d8] bg-white px-3 text-[14px] text-[#222] outline-none focus:border-[#888]"
|
||||
>
|
||||
<option value="">선택해 주세요.</option>
|
||||
{(field.options ?? []).map((option) => (
|
||||
<option key={option.id} value={option.key}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
: <div className="flex items-center gap-2">
|
||||
<input
|
||||
value={fieldValues[field.field_code] ?? ''}
|
||||
onChange={(event) =>
|
||||
updateFieldValue(
|
||||
field.field_code,
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
required={field.required}
|
||||
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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<th className="w-[140px] bg-[#fafafa] px-4 py-4 text-left align-top font-semibold text-[#333]">
|
||||
첨부파일
|
||||
</th>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="inline-flex cursor-pointer items-center rounded-[4px] border border-[#bdbdbd] bg-[#f7f7f7] px-3 py-2 text-sm text-[#333] hover:bg-[#f0f0f0]">
|
||||
파일 선택
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleSelectFiles}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-[#f36c21]">
|
||||
* 파일당 최대 30MB, 전체 최대 30MB 이내
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-[6px] border border-dashed border-[#d8d8d8] bg-[#fcfcfc] p-4 text-sm text-[#777]">
|
||||
{attachments.length === 0 ?
|
||||
<p>
|
||||
여기로 파일을 드래그하거나 클릭해서 선택하세요.
|
||||
</p>
|
||||
: <div className="flex flex-col gap-2">
|
||||
{attachments.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 rounded-[4px] border border-[#ececec] bg-white px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-[#333]">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="text-xs text-[#888]">
|
||||
{item.sizeLabel}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(item.id)}
|
||||
className="text-xs font-medium text-[#b42318]"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{submitMessage ?
|
||||
<div className="rounded-[6px] border border-[#dcdcdc] bg-[#fafafa] px-4 py-3 text-sm text-[#555]">
|
||||
{submitMessage}
|
||||
{createdTicketId ?
|
||||
<span className="ml-2 text-[#777]">
|
||||
등록번호: {createdTicketId}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link
|
||||
href={`/support/${encodedWorkspaceCode}/list`}
|
||||
className="rounded-[4px] border border-[#d0d0d0] bg-white px-5 py-3 text-sm font-medium text-[#333] transition hover:bg-[#f7f7f7]"
|
||||
>
|
||||
취소
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-[4px] bg-[#0f5f48] px-5 py-3 text-sm font-semibold text-white transition hover:bg-[#0a4f3b] disabled:cursor-not-allowed disabled:bg-[#8aa79d]"
|
||||
>
|
||||
{isSubmitting ? '작성중...' : '작성완료'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</SupportPortalShell>
|
||||
);
|
||||
};
|
||||
|
||||
SupportNewPage.getLayout = (page: React.ReactNode) => {
|
||||
return page;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default SupportNewPage;
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GetStaticProps } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Button, toast } from '@ufb/react';
|
||||
|
||||
import { AnonymousTemplate, Path, TextInput, useOAIMutation } from '@/shared';
|
||||
import type { NextPageWithLayout } from '@/shared/types';
|
||||
import { useTenantStore } from '@/entities/tenant';
|
||||
import type { SignUpWithEmailType } from '@/features/auth/sign-up-with-email';
|
||||
import { SignUpWithEmailForm } from '@/features/auth/sign-up-with-email';
|
||||
import { CreateTenantForm } from '@/features/create-tenant';
|
||||
import { AnonymousLayout } from '@/widgets/anonymous-layout';
|
||||
|
||||
import { env } from '@/env';
|
||||
import serverSideTranslations from '@/server-side-translations';
|
||||
|
||||
const CreateTenantPage: NextPageWithLayout = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [step, setStep] = useState<'tenant' | 'user' | 'final'>('tenant');
|
||||
|
||||
const [data, setData] = useState<{
|
||||
tenant: { siteName: string } | null;
|
||||
user: SignUpWithEmailType | null;
|
||||
}>({ tenant: null, user: null });
|
||||
|
||||
const { tenant } = useTenantStore();
|
||||
|
||||
const { mutate: createTenant, isPending } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/tenants',
|
||||
queryOptions: {
|
||||
async onSuccess() {
|
||||
await router.replace(Path.SIGN_IN);
|
||||
router.reload();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const onClickComplete = () => {
|
||||
if (!data.tenant || !data.user) return;
|
||||
createTenant({
|
||||
siteName: data.tenant.siteName,
|
||||
email: data.user.email,
|
||||
password: data.user.password,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (env.NODE_ENV === 'development') return;
|
||||
if (!tenant) return;
|
||||
void router.replace(Path.MAIN);
|
||||
}, [tenant]);
|
||||
|
||||
return (
|
||||
<AnonymousTemplate
|
||||
title={t('v2.create-tenant.tenant.title')}
|
||||
description={t(`v2.create-tenant.${step}.description`)}
|
||||
image={
|
||||
step === 'final' ?
|
||||
'/assets/images/complete-tenant-setting.svg'
|
||||
: '/assets/images/tenant-setting.svg'
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-[300px] flex-col">
|
||||
{step === 'tenant' && (
|
||||
<CreateTenantForm
|
||||
onSubmit={({ siteName }) => {
|
||||
setData((prev) => ({ ...prev, tenant: { siteName } }));
|
||||
void setStep('user');
|
||||
}}
|
||||
submitText={t('button.next')}
|
||||
defaultValues={data.tenant}
|
||||
/>
|
||||
)}
|
||||
{step === 'user' && (
|
||||
<SignUpWithEmailForm
|
||||
onSubmit={(user) => {
|
||||
setData((prev) => ({ ...prev, user }));
|
||||
void setStep('final');
|
||||
}}
|
||||
submitText={t('button.next')}
|
||||
initialValues={data.user}
|
||||
skipEmailVerification
|
||||
/>
|
||||
)}
|
||||
{step === 'final' && (
|
||||
<>
|
||||
<div className="mb-44 flex flex-col gap-4">
|
||||
<TextInput label="Name" value={data.tenant?.siteName} disabled />
|
||||
<TextInput label="Email" value={data.user?.email} disabled />
|
||||
</div>
|
||||
<Button
|
||||
size="medium"
|
||||
onClick={onClickComplete}
|
||||
loading={isPending}
|
||||
disabled={!data.tenant || !data.user}
|
||||
>
|
||||
{t('v2.button.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnonymousTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
CreateTenantPage.getLayout = (page) => {
|
||||
return <AnonymousLayout> {page}</AnonymousLayout>;
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ locale }) => {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default CreateTenantPage;
|
||||
Reference in new issue
Block a user