Initial deployment setup
Deploy staging / deploy (push) Failing after 6s

This commit is contained in:
root committed 2026-08-31 16:45:24 +09:00
commit 33453ecc55
3475 files changed
+850363

No files matched your search

+49
View File
@@ -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;
+47
View File
@@ -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;
+113
View File
@@ -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);
+32
View File
@@ -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;
+354
View File
@@ -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;
+24
View File
@@ -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;
+82
View File
@@ -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;
+77
View File
@@ -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;
+157
View File
@@ -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;
+70
View File
@@ -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;
+205
View File
@@ -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;
+29
View File
@@ -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;
+78
View File
@@ -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
+153
View File
@@ -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;
+112
View File
@@ -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;
+221
View File
@@ -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;
+122
View File
@@ -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;
+139
View File
@@ -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;