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

This commit is contained in:
root
2026-08-31 16:45:24 +09:00
commit 33453ecc55
3475 changed files with 850363 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
/**
* 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';
type ImageProps = Omit<
React.ComponentPropsWithoutRef<typeof Image>,
'src' | 'alt'
>;
export const NodataImage = (props: ImageProps) => {
return (
<Image
src="/assets/images/no-data.svg"
alt="noData"
width={200}
height={200}
{...props}
/>
);
};
export const NoDataWithSearchImage = (props: ImageProps) => {
return (
<Image
src="/assets/images/no-data-with-search.svg"
alt="no-data-with-search"
width={200}
height={200}
{...props}
/>
);
};
export const WatingImage = (props: ImageProps) => {
return (
<Image
src="/assets/images/waiting.svg"
alt="wating"
width={200}
height={200}
{...props}
/>
);
};
export const OpenAIIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.5624 10.1875C20.8124 9.5 20.8749 8.8125 20.8124 8.125C20.7499 7.4375 20.4999 6.75 20.1874 6.125C19.6249 5.1875 18.8124 4.4375 17.8749 4C16.8749 3.5625 15.8124 3.4375 14.7499 3.6875C14.2499 3.1875 13.6874 2.75 13.0624 2.4375C12.4374 2.125 11.6874 2 10.9999 2C9.9374 2 8.8749 2.3125 7.9999 2.9375C7.1249 3.5625 6.4999 4.4375 6.1874 5.4375C5.4374 5.625 4.81239 5.9375 4.18739 6.3125C3.62489 6.75 3.1874 7.3125 2.8124 7.875C2.24991 8.8125 2.06241 9.875 2.18741 10.9375C2.31241 12 2.7499 13 3.4374 13.8125C3.1874 14.5 3.1249 15.1875 3.1874 15.875C3.2499 16.5625 3.4999 17.25 3.8124 17.875C4.3749 18.8125 5.18739 19.5625 6.12489 20C7.1249 20.4375 8.1874 20.5625 9.2499 20.3125C9.7499 20.8125 10.3124 21.25 10.9374 21.5625C11.5624 21.875 12.3124 22 12.9999 22C14.0624 22 15.1249 21.6875 15.9999 21.0625C16.8749 20.4375 17.4999 19.5625 17.8124 18.5625C18.4999 18.4375 19.1874 18.125 19.7499 17.6875C20.3124 17.25 20.8124 16.75 21.1249 16.125C21.6874 15.1875 21.8749 14.125 21.7499 13.0625C21.6249 12 21.2499 11 20.5624 10.1875ZM13.0624 20.6875C12.0624 20.6875 11.3124 20.375 10.6249 19.8125C10.6249 19.8125 10.6874 19.75 10.7499 19.75L14.7499 17.4375C14.8749 17.375 14.9374 17.3125 14.9999 17.1875C15.0624 17.0625 15.0624 17 15.0624 16.875V11.25L16.7499 12.25V16.875C16.8124 19.0625 15.0624 20.6875 13.0624 20.6875ZM4.99989 17.25C4.56239 16.5 4.37489 15.625 4.56239 14.75C4.56239 14.75 4.62489 14.8125 4.68739 14.8125L8.6874 17.125C8.8124 17.1875 8.8749 17.1875 8.9999 17.1875C9.1249 17.1875 9.2499 17.1875 9.3124 17.125L14.1874 14.3125V16.25L10.1249 18.625C9.2499 19.125 8.2499 19.25 7.3124 19C6.3124 18.75 5.49989 18.125 4.99989 17.25ZM3.9374 8.5625C4.3749 7.8125 5.06239 7.25 5.87489 6.9375V7.0625V11.6875C5.87489 11.8125 5.87489 11.9375 5.93739 12C5.99989 12.125 6.0624 12.1875 6.1874 12.25L11.0624 15.0625L9.3749 16.0625L5.37489 13.75C4.49989 13.25 3.8749 12.4375 3.6249 11.5C3.3749 10.5625 3.4374 9.4375 3.9374 8.5625ZM17.7499 11.75L12.8749 8.9375L14.5624 7.9375L18.5624 10.25C19.1874 10.625 19.6874 11.125 19.9999 11.75C20.3124 12.375 20.4999 13.0625 20.4374 13.8125C20.3749 14.5 20.1249 15.1875 19.6874 15.75C19.2499 16.3125 18.6874 16.75 17.9999 17V12.25C17.9999 12.125 17.9999 12 17.9374 11.9375C17.9374 11.9375 17.8749 11.8125 17.7499 11.75ZM19.4374 9.25C19.4374 9.25 19.3749 9.1875 19.3124 9.1875L15.3124 6.875C15.1874 6.8125 15.1249 6.8125 14.9999 6.8125C14.8749 6.8125 14.7499 6.8125 14.6874 6.875L9.8124 9.6875V7.75L13.8749 5.375C14.4999 5 15.1874 4.875 15.9374 4.875C16.6249 4.875 17.3124 5.125 17.9374 5.5625C18.4999 6 18.9999 6.5625 19.2499 7.1875C19.4999 7.8125 19.5624 8.5625 19.4374 9.25ZM8.9374 12.75L7.2499 11.75V7.0625C7.2499 6.375 7.4374 5.625 7.8124 5.0625C8.1874 4.4375 8.7499 4 9.3749 3.6875C9.9999 3.375 10.7499 3.25 11.4374 3.375C12.1249 3.4375 12.8124 3.75 13.3749 4.1875C13.3749 4.1875 13.3124 4.25 13.2499 4.25L9.2499 6.5625C9.1249 6.625 9.0624 6.6875 8.9999 6.8125C8.9374 6.9375 8.9374 7 8.9374 7.125V12.75ZM9.8124 10.75L11.9999 9.5L14.1874 10.75V13.25L11.9999 14.5L9.8124 13.25V10.75Z"
fill="currentColor"
/>
</svg>
);
};
export const GeminiIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_8062_155)">
<path
d="M23.9996 12.0235C17.5625 12.4117 12.4114 17.563 12.0232 24H11.9762C11.588 17.563 6.4369 12.4117 0 12.0235V11.9765C6.4369 11.5883 11.588 6.43719 11.9762 0H12.0232C12.4114 6.43719 17.5625 11.5883 23.9996 11.9765V12.0235Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_8062_155">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
);
};
export const AISparklingIcon = (props: ImageProps) => {
return (
<Image
src="/assets/images/ai-sparkling-fill.svg"
alt="ai-sparkling-fill"
width={16}
height={16}
{...props}
/>
);
};
export const AIGenerateIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 3.59961H12.4004V4.40039H6C4.56406 4.40039 3.40039 5.56406 3.40039 7V17C3.40039 18.436 4.56406 19.5996 6 19.5996H18C19.436 19.5996 20.5996 18.436 20.5996 17V12.5996H21.4004V17C21.4004 18.8777 19.8777 20.4004 18 20.4004H6C4.12223 20.4004 2.59961 18.8777 2.59961 17V7C2.59961 5.12223 4.12223 3.59961 6 3.59961ZM19.1973 2.59961C19.4547 4.24564 20.7544 5.54527 22.4004 5.80273V6.19629C20.7543 6.4537 19.4547 7.75432 19.1973 9.40039H18.8027C18.5453 7.75432 17.2457 6.4537 15.5996 6.19629V5.80273C17.2456 5.54527 18.5453 4.24564 18.8027 2.59961H19.1973Z"
fill="url(#paint0_linear_12996_14077)"
stroke="url(#paint1_linear_12996_14077)"
stroke-width="1.2"
/>
<defs>
<linearGradient
id="paint0_linear_12996_14077"
x1="12.5"
y1="2"
x2="12.5"
y2="21"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2DD4BF" />
<stop offset="1" stop-color="#0EA5E9" />
</linearGradient>
<linearGradient
id="paint1_linear_12996_14077"
x1="12.5"
y1="2"
x2="12.5"
y2="21"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2DD4BF" />
<stop offset="1" stop-color="#0EA5E9" />
</linearGradient>
</defs>
</svg>
);
};
+43
View File
@@ -0,0 +1,43 @@
/**
* 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 { z } from 'zod';
export const aiSchema = z.object({
provider: z.enum(['OPEN_AI', 'GEMINI']),
apiKey: z.string().trim().min(1, { message: 'API Key is required' }),
endpointUrl: z.string().trim(),
systemPrompt: z.string().trim(),
tokenThreshold: z.number().nullable(),
notificationThreshold: z.number().nullable(),
});
export const aiTemplateSchema = z.object({
title: z.string().trim().min(1).max(30),
prompt: z.string().trim().min(1),
model: z.string(),
temperature: z.number(),
});
export const aiIssueSchema = z.object({
channelId: z.number().int(),
targetFieldKeys: z.array(z.string()).min(1),
prompt: z.string().trim(),
isEnabled: z.boolean(),
model: z.string(),
temperature: z.number(),
dataReferenceAmount: z.number(),
});
+22
View File
@@ -0,0 +1,22 @@
/**
* 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 { z } from 'zod';
import type { aiIssueSchema, aiSchema, aiTemplateSchema } from './ai.schema';
export type AI = z.infer<typeof aiSchema>;
export type AITemplate = z.infer<typeof aiTemplateSchema>;
export type AIIssue = z.infer<typeof aiIssueSchema>;
+19
View File
@@ -0,0 +1,19 @@
/**
* 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.
*/
export * from './ui';
export * from './ai.type';
export * from './ai.schema';
export * from './lib';
+16
View File
@@ -0,0 +1,16 @@
/**
* 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.
*/
export { default as useCheckAIUsageLimit } from './use-check-ai-usage-limit';
@@ -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 { useEffect } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { useLocalStorage } from 'react-use';
import { Icon, toast } from '@ufb/react';
import { useOAIQuery } from '@/shared';
const useCheckAIUsageLimit = (projectId: number) => {
const { t } = useTranslation();
const { data: aiIntegrations } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/integrations',
variables: { projectId },
});
const { data: aiUsageLimit } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/usages',
variables: {
projectId,
from: dayjs().startOf('month').toISOString(),
to: dayjs().endOf('month').toISOString(),
},
});
const [checkedTotalLimitTime, setCheckedTotalLimitTime] =
useLocalStorage<string>(
`totalLimit-${projectId}-${dayjs().format('YYYY-MM')}`,
);
const [isCheckedNotificationLimit, setIsCheckedNotificationLimit] =
useLocalStorage(
`notificationLimit-${projectId}-${dayjs().format('YYYY-MM')}`,
false,
);
useEffect(() => {
if (!aiIntegrations?.apiKey) return;
if (aiUsageLimit) {
const totalUsage = aiUsageLimit.reduce(
(acc, usage) => acc + usage.usedTokens,
0,
);
const totalLimit = aiIntegrations.tokenThreshold;
if (!totalLimit) return;
if (totalUsage >= totalLimit) {
if (
!checkedTotalLimitTime ||
dayjs(checkedTotalLimitTime).add(1, 'day').isBefore(dayjs())
) {
setCheckedTotalLimitTime(dayjs().toISOString());
const toastId = toast.error(
t('v2.toast.ai-function-terminated.title'),
{
description: t('v2.toast.ai-function-terminated.description'),
cancel: {
label: <Icon name="RiCloseFill" size={20} />,
onClick: () => {
toast.dismiss(toastId);
},
},
duration: Infinity,
},
);
}
return;
}
const notificationLimit = aiIntegrations.notificationThreshold;
if (!notificationLimit) return;
if (totalUsage >= notificationLimit) {
if (!isCheckedNotificationLimit) {
const toastId = toast.warning(
t('v2.toast.ai-usage-limit-approaching.title'),
{
description: t(
'v2.toast.ai-usage-limit-approaching.description',
{
percentage: Math.round(
(notificationLimit / totalLimit) * 100,
),
},
),
cancel: {
label: <Icon name="RiCloseFill" size={20} />,
onClick: () => {
toast.dismiss(toastId);
},
},
duration: Infinity,
},
);
setIsCheckedNotificationLimit(true);
}
return;
}
}
}, [aiUsageLimit, aiIntegrations]);
};
export default useCheckAIUsageLimit;
@@ -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 { memo } from 'react';
import { Icon } from '@ufb/react';
import { LoadingBars } from '@/features/update-ai-setting/ui/playground-output-card.ui';
interface Props {
value:
| { status: 'loading' | 'success' | 'error'; message: string }
| undefined;
isLoading?: boolean;
}
const AICell = memo((props: Props) => {
const { value, isLoading } = props;
if (!value) return null;
return (
<>
{value.status === 'loading' || isLoading ?
<LoadingBars width={410} />
: value.status === 'error' ?
<div className="text-tint-red">
<Icon name="RiErrorWarningFill" className="mr-2" size={16} />
<span>{value.message}</span>
</div>
: <p>{value.message}</p>}
</>
);
});
export default AICell;
@@ -0,0 +1,102 @@
/**
* 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 { useTranslation } from 'next-i18next';
import { useFormContext } from 'react-hook-form';
import { FormField, RadioCard, RadioCardGroup } from '@ufb/react';
import { FormInput, FormTextarea } from '@/shared/ui/form-inputs';
import { GeminiIcon, OpenAIIcon } from '@/assets';
import type { AI } from '../ai.type';
const AiSettingForm = () => {
const { setValue, watch, control } = useFormContext<AI>();
const { t } = useTranslation();
const [previousValues, setPreviousValues] = useState<
Record<AI['provider'], { apiKey: string; endpointUrl: string }>
>({
GEMINI: { apiKey: '', endpointUrl: '' },
OPEN_AI: { apiKey: '', endpointUrl: '' },
});
return (
<div className="flex flex-col gap-4">
<RadioCardGroup
value={watch('provider')}
onValueChange={(currentProvider: 'OPEN_AI' | 'GEMINI') => {
const {
apiKey: previousApiKey,
provider: previousProvider,
endpointUrl: previousEndpointUrl,
} = watch();
setPreviousValues((prev) => ({
...prev,
[previousProvider]: {
apiKey: previousApiKey,
endpointUrl: previousEndpointUrl,
},
}));
setValue('provider', currentProvider, { shouldDirty: true });
setValue('apiKey', previousValues[currentProvider].apiKey);
setValue('endpointUrl', previousValues[currentProvider].endpointUrl);
}}
>
<RadioCard value="OPEN_AI" icon={<OpenAIIcon />} title="Open AI" />
<RadioCard value="GEMINI" icon={<GeminiIcon />} title="Gemini" />
</RadioCardGroup>
<FormField
control={control}
name="apiKey"
render={({ field }) => (
<FormInput
label="API Key"
placeholder={t('v2.placeholder.text')}
{...field}
required
/>
)}
/>
<FormField
control={control}
name="endpointUrl"
render={({ field }) => (
<FormInput
label="Endpoint URL"
placeholder={t('v2.placeholder.ai-endpoint-url')}
{...field}
/>
)}
/>
<FormField
control={control}
name="systemPrompt"
render={({ field }) => (
<FormTextarea
label="System Prompt"
placeholder={t('v2.placeholder.system-prompt')}
{...field}
/>
)}
/>
</div>
);
};
export default AiSettingForm;
+17
View File
@@ -0,0 +1,17 @@
/**
* 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.
*/
export { default as AiSettingForm } from './ai-setting-form.ui';
export { default as AICell } from './ai-feedback-cell.ui';
@@ -0,0 +1,52 @@
/**
* 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 { createColumnHelper } from '@tanstack/react-table';
import dayjs from 'dayjs';
import { Badge } from '@ufb/react';
import { CopyIconButton, DATE_TIME_FORMAT } from '@/shared';
import type { ApiKey } from './api-key.type';
const columnHelper = createColumnHelper<ApiKey>();
export const getApiKeyColumns = () => [
columnHelper.accessor('value', {
header: 'API KEY',
cell: ({ getValue }) => (
<div className="text-base-strong flex items-center">
{getValue()}
<CopyIconButton data={getValue()} />
</div>
),
enableSorting: false,
}),
columnHelper.accessor('deletedAt', {
header: 'Status',
cell: ({ getValue }) => (
<Badge color={getValue() === null ? 'green' : 'red'} radius="large">
{getValue() === null ? 'Active' : 'Inactive'}
</Badge>
),
enableSorting: false,
}),
columnHelper.accessor('createdAt', {
header: 'Created',
cell: ({ getValue }) => dayjs(getValue()).format(DATE_TIME_FORMAT),
}),
];
@@ -0,0 +1,28 @@
/**
* 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 { z } from 'zod';
export const apiKeySchema = z.object({
id: z.number(),
value: z.string(),
createdAt: z.string(),
deletedAt: z.string().nullable(),
});
export const apiKeyFormSchema = z.object({
value: z.string(),
status: z.union([z.literal('active'), z.literal('inactive')]),
});
@@ -0,0 +1,23 @@
/**
* 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 { z } from 'zod';
import type { apiKeyFormSchema, apiKeySchema } from './api-key.schema';
export type ApiKey = z.infer<typeof apiKeySchema>;
export type ApiKeyUpdateType = 'recover' | 'softDelete';
export type ApiKeyFormSchema = z.infer<typeof apiKeyFormSchema>;
+17
View File
@@ -0,0 +1,17 @@
/**
* 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.
*/
export * from './ui';
export * from './api-key.type';
@@ -0,0 +1,84 @@
/**
* 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 { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'next-i18next';
import { useForm } from 'react-hook-form';
import type { FormOverlayProps } from '@/shared';
import { FormDialog, SelectInput, TextInput } from '@/shared';
import { apiKeyFormSchema } from '../api-key.schema';
import type { ApiKeyFormSchema } from '../api-key.type';
interface Props extends FormOverlayProps<ApiKeyFormSchema> {}
const ApiKeyFormDialog: React.FC<Props> = (props) => {
const {
data,
close,
isOpen,
onSubmit,
onClickDelete,
disabledDelete,
disabledUpdate,
} = props;
const { t } = useTranslation();
const { register, watch, setValue, handleSubmit, formState } =
useForm<ApiKeyFormSchema>({
resolver: zodResolver(apiKeyFormSchema),
defaultValues: data,
});
const { status } = watch();
return (
<FormDialog
isOpen={isOpen}
close={close}
title={t('v2.text.name.detail', { name: 'API Key' })}
submitBtn={{ form: 'apiKeyForm', disabled: disabledUpdate }}
deleteBtn={{
disabled: !!disabledDelete,
onClick: onClickDelete,
}}
formState={formState}
>
<form
id="apiKeyForm"
className="flex flex-col gap-4"
onSubmit={handleSubmit(onSubmit)}
>
<TextInput {...register('value')} label="API Key" disabled />
<SelectInput
label="Status"
options={[
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
]}
value={status}
onChange={(value) =>
setValue('status', value as 'active' | 'inactive', {
shouldDirty: true,
})
}
/>
</form>
</FormDialog>
);
};
export default ApiKeyFormDialog;
@@ -0,0 +1,94 @@
/**
* 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 } from 'react';
import {
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useOverlay } from '@toss/use-overlay';
import type { EntityTable } from '@/shared';
import { BasicTable } from '@/shared';
import { getApiKeyColumns } from '../api-key-columns';
import type { ApiKey, ApiKeyUpdateType } from '../api-key.type';
import ApiKeyFormDialog from './api-key-form-dialog.ui';
interface IProps extends Omit<EntityTable<ApiKey>, 'onClickRow'> {
onClickDelete?: (id: number) => Promise<void> | void;
onClickUpdate?: (type: ApiKeyUpdateType, id: number) => Promise<void> | void;
disabledUpdate?: boolean;
}
const ApiKeyTable: React.FC<IProps> = (props) => {
const {
isLoading,
onClickDelete,
onClickUpdate,
createButton,
data,
disabledUpdate,
disabledDelete,
} = props;
const overlay = useOverlay();
const columns = useMemo(() => getApiKeyColumns(), []);
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const openApiKeyDialog = (apiKey: ApiKey) => {
overlay.open(({ close, isOpen }) => (
<ApiKeyFormDialog
close={close}
isOpen={isOpen}
data={{
status: apiKey.deletedAt === null ? 'active' : 'inactive',
value: apiKey.value,
}}
onSubmit={async (input) => {
await onClickUpdate?.(
input.status === 'active' ? 'recover' : 'softDelete',
apiKey.id,
);
close();
}}
onClickDelete={async () => {
await onClickDelete?.(apiKey.id);
close();
}}
disabledDelete={disabledDelete}
disabledUpdate={disabledUpdate}
/>
));
};
return (
<BasicTable
isLoading={isLoading}
table={table}
createButton={createButton}
onClickRow={(_, row) => openApiKeyDialog(row)}
/>
);
};
export default ApiKeyTable;
+16
View File
@@ -0,0 +1,16 @@
/**
* 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.
*/
export { default as ApiKeyTable } from './api-key-table.ui';
@@ -0,0 +1,22 @@
/**
* 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 { z } from 'zod';
export const categorySchema = z.object({
id: z.number(),
name: z.string().trim(),
});
@@ -0,0 +1,20 @@
/**
* 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 { z } from 'zod';
import type { categorySchema } from './category.schema';
export type Category = z.infer<typeof categorySchema>;
+17
View File
@@ -0,0 +1,17 @@
/**
* 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.
*/
export * from './category.type';
export * from './ui';
@@ -0,0 +1,16 @@
/**
* 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.
*/
export { default as useCategorySearchInfinite } from './use-category-search-infinite';
@@ -0,0 +1,76 @@
/**
* 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 { useInfiniteQuery } from '@tanstack/react-query';
import type { OAIMutationResponse, OAIRequestBody } from '@/shared';
import { client } from '@/shared';
type TData = OAIMutationResponse<
'/api/admin/projects/{projectId}/categories/search',
'post'
>;
interface IBody
extends OAIRequestBody<
'/api/admin/projects/{projectId}/categories/search',
'post'
> {}
const useCategorySearchInfinite = (
projectId: number,
body: IBody = { limit: 10, page: 1, sort: {} },
) => {
return useInfiniteQuery<TData>({
queryKey: [
'/api/admin/projects/{projectId}/categories/search',
projectId,
body,
],
queryFn: async ({ pageParam }) => {
const { data: result } = await client.post({
path: '/api/admin/projects/{projectId}/categories/search',
pathParams: { projectId },
body: { ...body, page: pageParam as number },
});
return result;
},
getNextPageParam: (lastPage) => {
if (!lastPage) return undefined;
if (lastPage.meta.currentPage < lastPage.meta.totalPages) {
return lastPage.meta.currentPage + 1;
}
return undefined;
},
initialPageParam: 1,
initialData: {
pageParams: [],
pages: [
{
items: [],
meta: {
currentPage: 1,
totalPages: 0,
totalItems: 0,
itemCount: 0,
itemsPerPage: 0,
},
},
],
},
});
};
export default useCategorySearchInfinite;
@@ -0,0 +1,125 @@
/**
* 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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import {
Badge,
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
Icon,
TextInput,
toast,
} from '@ufb/react';
import { client } from '@/shared';
import type { Category } from '@/entities/category';
interface Props {
projectId: number;
cateogry: Category;
}
const CategoryComboboxEditPopover = (props: Props) => {
const { projectId, cateogry } = props;
const { t } = useTranslation();
const queryClient = useQueryClient();
const refetch = async () => {
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/categories/search'],
});
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
});
};
const [inputValue, setInputValue] = useState(cateogry.name);
const { mutate: updateCategory } = useMutation({
mutationFn: async ({ name }: { name: string }) => {
const { data } = await client.put({
path: '/api/admin/projects/{projectId}/categories/{categoryId}',
pathParams: { projectId, categoryId: cateogry.id },
body: { name },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
const { mutate: deleteCategory } = useMutation({
mutationFn: async () => {
const { data } = await client.delete({
path: '/api/admin/projects/{projectId}/categories/{categoryId}',
pathParams: { projectId, categoryId: cateogry.id },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
return (
<Dropdown>
<DropdownTrigger asChild>
<Badge variant="subtle" onClick={(e) => e.stopPropagation()}>
Edit
</Badge>
</DropdownTrigger>
<DropdownContent onClick={(e) => e.stopPropagation()} side="right">
<div className="border-b">
<TextInput
className="border-none"
value={inputValue}
onChange={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
const name = inputValue.trim();
updateCategory({ name });
setInputValue(name);
}
}}
/>
</div>
<DropdownItem
className="gap-2"
onSelect={() => deleteCategory(undefined)}
>
<Icon name="RiDeleteBin5Fill" size={16} />
Delete Category
</DropdownItem>
</DropdownContent>
</Dropdown>
);
};
export default CategoryComboboxEditPopover;
@@ -0,0 +1,242 @@
/**
* 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, { useMemo, useState } from 'react';
import { useRouter } from 'next/router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { useThrottle } from 'react-use';
import {
Combobox,
ComboboxContent,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
toast,
} from '@ufb/react';
import {
client,
cn,
commandFilter,
InfiniteScrollArea,
usePermissions,
} from '@/shared';
import type { Category } from '@/entities/category';
import { useCategorySearchInfinite } from '../lib';
import CategoryComboboxEditPopover from './category-combobox-edit-popover.ui';
interface Props extends React.PropsWithChildren {
category?: Category | null;
issueId: number;
projectId?: number;
}
const CategoryCombobox = (props: Props) => {
const { category, issueId, children, projectId: providedProjectId } = props;
const perms = usePermissions();
const router = useRouter();
const routeProjectId = Number(router.query.projectId);
const projectId = providedProjectId ?? routeProjectId;
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const throttledvalue = useThrottle(inputValue, 500);
const queryClient = useQueryClient();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useCategorySearchInfinite(Number(projectId), {
limit: 10,
categoryName: throttledvalue,
sort: { name: 'ASC' },
});
const allcategories = useMemo(() => {
return data.pages
.map((v) => v?.items)
.filter((v) => !!v)
.flat();
}, [data]);
const refetch = async () => {
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/categories/search'],
});
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
});
};
const { mutate: attachCategory } = useMutation({
mutationFn: async ({ categoryId }: { categoryId: number }) => {
const { data } = await client.put({
path: '/api/admin/projects/{projectId}/issues/{issueId}/category/{categoryId}',
pathParams: { projectId, issueId, categoryId },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
const { mutate: detachCategory } = useMutation({
mutationFn: async ({ categoryId }: { categoryId: number }) => {
const { data } = await client.delete({
path: '/api/admin/projects/{projectId}/issues/{issueId}/category/{categoryId}',
pathParams: { projectId, issueId, categoryId },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
const { mutateAsync: createCategory } = useMutation({
mutationFn: async ({ name }: { name: string }) => {
const { data } = await client.post({
path: '/api/admin/projects/{projectId}/categories',
pathParams: { projectId },
body: { name },
});
return data;
},
async onSuccess() {
await refetch();
setInputValue('');
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
return (
<Combobox>
<ComboboxTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
disabled={!perms.includes('issue_update')}
className={cn(
{ 'opacity-50': !perms.includes('issue_update') },
'w-fit',
)}
>
{children}
</button>
</ComboboxTrigger>
<ComboboxContent options={{ filter: commandFilter }}>
<ComboboxInput
onClick={(e) => e.stopPropagation()}
onValueChange={(value) => setInputValue(value)}
value={inputValue}
/>
<ComboboxList onClick={(e) => e.stopPropagation()} maxHeight="200px">
<ComboboxGroup
heading={
<span className="text-neutral-tertiary text-base-normal">
Selected Category
</span>
}
>
{allcategories
.filter((v) => category?.id === v.id)
.map(({ id, name }) => (
<ComboboxItem
key={id}
value={name}
onSelect={() => {
if (category?.id === id) detachCategory({ categoryId: id });
if (category?.id !== id) attachCategory({ categoryId: id });
}}
>
<span className="flex-1">{name}</span>
<span className="text-neutral-tertiary text-small-normal">
Remove
</span>
</ComboboxItem>
))}
</ComboboxGroup>
<ComboboxGroup
heading={
<span className="text-neutral-tertiary text-base-normal">
Category List
</span>
}
>
{allcategories
.filter((v) => category?.id !== v.id)
.map(({ id, name }) => (
<ComboboxItem
key={id}
value={name}
onSelect={() => {
if (category?.id === id) detachCategory({ categoryId: id });
if (category?.id !== id) attachCategory({ categoryId: id });
}}
>
<span className="flex-1">{name}</span>
<CategoryComboboxEditPopover
projectId={projectId}
cateogry={{ id, name }}
/>
</ComboboxItem>
))}
<InfiniteScrollArea
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
</ComboboxGroup>
{!!inputValue &&
perms.includes('issue_update') &&
!allcategories.some((v) => v.name === inputValue) && (
<div
className="combobox-item"
onClick={async () => {
const name = inputValue.trim();
const data = await createCategory({ name });
if (!data) return;
setInputValue(name);
attachCategory({ categoryId: data.id });
}}
>
<span className="flex-1">{inputValue}</span>
<span className="text-neutral-tertiary text-small-normal">
Create
</span>
</div>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};
export default CategoryCombobox;
@@ -0,0 +1,16 @@
/**
* 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.
*/
export { default as CategoryCombobox } from './category-combobox.ui';
@@ -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 { i18n } from 'next-i18next';
import { z } from 'zod';
export const channelImageConfigSchema = z.object({
accessKeyId: z.string().trim(),
secretAccessKey: z.string().trim(),
endpoint: z.string().trim(),
region: z.string().trim(),
bucket: z.string().trim(),
domainWhiteList: z.array(z.string().trim()).nullable(),
enablePresignedUrlDownload: z.boolean().optional(),
});
export const channelSchema = z.object({
id: z.number(),
name: z
.string()
.trim()
.min(1, { message: i18n?.t('hint.required') })
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
description: z
.string()
.trim()
.max(50, { message: i18n?.t('hint.max-length', { length: 50 }) })
.nullable(),
createdAt: z.string(),
updatedAt: z.string(),
imageConfig: channelImageConfigSchema.nullable().optional(),
feedbackSearchMaxDays: z.number(),
});
export const channelInfoSchema = channelSchema
.pick({ name: true, description: true, feedbackSearchMaxDays: true })
.merge(z.object({ id: z.number().optional() }));
@@ -0,0 +1,27 @@
/**
* 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 { z } from 'zod';
import type {
channelImageConfigSchema,
channelInfoSchema,
channelSchema,
} from './channel.schema';
export type Channel = z.infer<typeof channelSchema>;
export type ChannelInfo = z.infer<typeof channelInfoSchema>;
export type ChannelImageConfig = z.infer<typeof channelImageConfigSchema>;
+18
View File
@@ -0,0 +1,18 @@
/**
* 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.
*/
export * from './channel.type';
export * from './channel.schema';
export * from './ui';
@@ -0,0 +1,16 @@
/**
* 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.
*/
export { default as useRoutingChannelCreation } from './use-routing-channel-creation';
@@ -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 { useRouter } from 'next/router';
import { useOverlay } from '@toss/use-overlay';
import { CreatingDialog, Path } from '@/shared';
import { useCreateChannelStore } from '@/features/create-channel';
const useRoutingChannelCreation = (projectId: number) => {
const { editingStepIndex, reset, jumpStepByIndex } = useCreateChannelStore();
const overlay = useOverlay();
const router = useRouter();
const openChannelInProgress = async () => {
if (editingStepIndex !== null) {
await new Promise<boolean>((resolve) =>
overlay.open(({ close, isOpen }) => (
<CreatingDialog
isOpen={isOpen}
close={close}
type="Channel"
onRestart={() => {
reset();
resolve(true);
}}
onContinue={() => {
jumpStepByIndex(editingStepIndex);
resolve(true);
}}
/>
)),
);
}
await router.push({ pathname: Path.CREATE_CHANNEL, query: { projectId } });
};
const isCreatingChannel = editingStepIndex !== null;
return {
openChannelInProgress,
isCreatingChannel,
};
};
export default useRoutingChannelCreation;
@@ -0,0 +1,83 @@
/**
* 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 { useTranslation } from 'next-i18next';
import { useFormContext } from 'react-hook-form';
import { SelectInput, TextInput } from '@/shared';
import type { ChannelInfo } from '../channel.type';
interface IProps {
type?: 'create' | 'update';
readOnly?: boolean;
}
const ChannelInfoForm: React.FC<IProps> = (props) => {
const { type = 'create', readOnly = false } = props;
const { t } = useTranslation();
const { register, formState, setValue, watch } =
useFormContext<ChannelInfo>();
const feedbackSearchMaxDays = watch('feedbackSearchMaxDays');
return (
<div className="flex flex-col gap-4">
{type === 'update' && (
<TextInput label="ID" {...register('id')} disabled />
)}
<TextInput
label="Name"
{...register('name')}
placeholder={t('v2.placeholder.text')}
required
disabled={readOnly}
error={formState.errors.name?.message}
/>
<TextInput
label="Description"
{...register('description')}
placeholder={t('v2.placeholder.text')}
disabled={readOnly}
error={formState.errors.description?.message}
/>
<SelectInput
label="Maximum feedback search period"
value={
feedbackSearchMaxDays ? String(feedbackSearchMaxDays) : undefined
}
onChange={(value) => {
if (!value) return;
setValue('feedbackSearchMaxDays', Number(value), {
shouldDirty: true,
});
}}
options={[
{ value: '30', label: t('text.date.before-days', { day: 30 }) },
{ value: '90', label: t('text.date.before-days', { day: 90 }) },
{ value: '180', label: t('text.date.before-days', { day: 180 }) },
{ value: '365', label: t('text.date.before-days', { day: 365 }) },
{ value: '-1', label: t('text.date.entire-period') },
]}
disabled={readOnly}
error={formState.errors.feedbackSearchMaxDays?.message}
/>
</div>
);
};
export default ChannelInfoForm;
@@ -0,0 +1,259 @@
/**
* 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 { useTranslation } from 'next-i18next';
import { useFormContext } from 'react-hook-form';
import { Badge, Button, Divider, Icon, toast } from '@ufb/react';
import {
ComboboxInputbox,
SelectInput,
TextInput,
useOAIMutation,
} from '@/shared';
import type { ChannelImageConfig } from '../channel.type';
interface IProps {
readOnly?: boolean;
}
const ImageConfigForm: React.FC<IProps> = (props) => {
const { readOnly = false } = props;
const { t } = useTranslation();
const [inputDomain, setInputDomain] = useState('');
const [connectionError, setConnectionError] = useState(false);
const {
register,
formState,
watch,
setValue,
setError,
clearErrors,
getValues,
} = useFormContext<ChannelImageConfig>();
const domainWhiteList = watch('domainWhiteList');
const addDomainWhiteList = (value: string) => {
if (domainWhiteList?.includes(value)) {
setError('domainWhiteList', {
message: t('hint.name-already-exists', { name: 'Domain' }),
});
return;
}
if (!/[a-z]+\.[a-z]{2,3}/.test(value)) {
setError('domainWhiteList', { message: t('hint.invalid-domain') });
return;
}
setValue('domainWhiteList', (domainWhiteList ?? []).concat(value), {
shouldDirty: true,
});
clearErrors('domainWhiteList');
setInputDomain('');
};
const removeDomainWhiteList = (index: number) => {
if (!domainWhiteList) return;
setValue(
'domainWhiteList',
domainWhiteList.filter((_, i) => i !== index),
{ shouldDirty: true },
);
};
const { mutate: testConection } = useOAIMutation({
method: 'post',
path: '/api/admin/projects/{projectId}/channels/image-upload-url-test',
pathParams: { projectId: 0 },
queryOptions: {
onSuccess(data) {
if (data?.success) {
toast.success(t('v2.toast.success'));
} else {
setError('accessKeyId', { message: '' });
setError('bucket', { message: '' });
setError('endpoint', { message: '' });
setError('region', { message: '' });
setError('root', { message: '' });
setError('secretAccessKey', { message: '' });
toast.error('Test Connection failed');
setConnectionError(false);
setConnectionError(true);
}
},
onError() {
toast.error('Test Connection failed');
setConnectionError(true);
},
},
});
const handleTestConnection = () => {
let isError = false;
const { accessKeyId, bucket, endpoint, region, secretAccessKey } =
getValues();
if (accessKeyId.length === 0) {
setError('accessKeyId', { message: t('hint.required') });
isError = true;
}
if (bucket.length === 0) {
setError('bucket', { message: t('hint.required') });
isError = true;
}
if (endpoint.length === 0) {
setError('endpoint', { message: t('hint.required') });
isError = true;
}
if (region.length === 0) {
setError('region', { message: t('hint.required') });
isError = true;
}
if (secretAccessKey.length === 0) {
setError('secretAccessKey', { message: t('hint.required') });
isError = true;
}
if (isError) {
setConnectionError(true);
return;
}
testConection(getValues());
};
return (
<div>
<div className="border-neutral-tertiary mb-4 flex flex-col gap-6 rounded border p-4">
<h5 className="text-title-h5">
{t('title-box.image-storage-integration')}
</h5>
<div className="flex flex-col gap-4">
<TextInput
label="Access Key ID"
placeholder={t('v2.placeholder.text')}
{...register('accessKeyId')}
error={formState.errors.accessKeyId?.message}
disabled={readOnly}
/>
<TextInput
{...register('secretAccessKey')}
label="Secret Access Key"
placeholder={t('v2.placeholder.text')}
error={formState.errors.secretAccessKey?.message}
disabled={readOnly}
/>
<TextInput
{...register('endpoint')}
label="End Point"
placeholder={t('v2.placeholder.text')}
error={formState.errors.endpoint?.message}
disabled={readOnly}
/>
<TextInput
{...register('region')}
label="Region"
placeholder={t('v2.placeholder.text')}
error={formState.errors.region?.message}
disabled={readOnly}
/>
<TextInput
{...register('bucket')}
label="Bucket Name"
placeholder={t('v2.placeholder.text')}
error={formState.errors.bucket?.message}
disabled={readOnly}
/>
<SelectInput
options={[
{ label: 'Enable', value: 'true' },
{ label: 'Disable', value: 'false' },
]}
value={
typeof watch('enablePresignedUrlDownload') !== 'undefined' ?
String(watch('enablePresignedUrlDownload'))
: ''
}
placeholder={t('v2.placeholder.select')}
onChange={(v) =>
setValue('enablePresignedUrlDownload', v === 'true', {
shouldDirty: true,
})
}
label="Presigned URL Download"
/>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
className="btn btn-secondary"
onClick={handleTestConnection}
>
Test Connection
</Button>
{connectionError && (
<div className="text-tint-red flex items-center gap-1">
<Icon name="RiErrorWarningFill" size={20} />
<span>{t('v2.text.test-connection-failed')}</span>
</div>
)}
</div>
</div>
<div className="border-neutral-tertiary flex flex-col gap-2 rounded border p-4">
<h5 className="text-title-h5">Image URL Domain Whitelist</h5>
<div className="flex gap-4">
<ComboboxInputbox
clearError={() => clearErrors('domainWhiteList')}
inputValue={inputDomain}
setInputValue={setInputDomain}
onSelectValue={addDomainWhiteList}
error={formState.errors.domainWhiteList?.message}
placeholder="example.com"
>
Whitelist
</ComboboxInputbox>
<Divider orientation="vertical" className="h-5" variant="subtle" />
<div className="flex items-center gap-2">
{!domainWhiteList || domainWhiteList.length === 0 ?
<p className="text-neutral-tertiary">
{t('v2.text.all-image-url-allow')}
</p>
: domainWhiteList.map((domain, index) => (
<Badge
key={index}
className="flex items-center"
variant="subtle"
>
{domain}
<Icon
name="RiCloseLine"
onClick={() => removeDomainWhiteList(index)}
size={16}
/>
</Badge>
))
}
</div>
</div>
</div>
</div>
);
};
export default ImageConfigForm;
+17
View File
@@ -0,0 +1,17 @@
/**
* 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.
*/
export { default as ChannelInfoForm } from './channel-info-form.ui';
export { default as ImageConfigForm } from './image-config-form.ui';
+17
View File
@@ -0,0 +1,17 @@
/**
* 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.
*/
export * from './ui';
export * from './lib';
@@ -0,0 +1,17 @@
/**
* 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.
*/
export { default as useLineChartData } from './use-line-chart-data';
export * from './use-admin-dashboard-overview';
@@ -0,0 +1,107 @@
/**
* 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 { useQuery } from '@tanstack/react-query';
import { client } from '@/shared';
export type DashboardStatusCount = {
status: string;
count: number;
};
export type DashboardTodoIssue = {
id: number;
name: string;
status: string;
externalIssueId: string | null;
externalIssueUrl: string | null;
externalIssueStatus: string | null;
externalIssueSyncStatus: string;
description: string | null;
};
export type DashboardTodo = {
feedbackId: number;
projectId: number;
projectName: string;
channelId: number;
title: string;
contents: string;
feedbackStatus: string;
priority: string | null;
category: string | null;
isSecret: boolean;
requesterName: string | null;
requesterEmail: string | null;
requesterPhoneNumber: string | null;
createdAt: string;
updatedAt: string;
issues: DashboardTodoIssue[];
todoReasons: string[];
};
export type AdminDashboardOverview = {
range: { from: string; to: string };
summary: {
totalFeedback: number;
waitingReply: number;
issueLinkedRate: number;
averageProcessingMinutes: number | null;
totalIssues: number;
};
projects: {
projectId: number;
projectName: string;
feedbackCount: number;
waitingReplyCount: number;
issueCount: number;
issueLinkedCount: number;
issueLinkedRate: number;
averageProcessingMinutes: number | null;
feedbackStatusCounts: DashboardStatusCount[];
issueStatusCounts: DashboardStatusCount[];
}[];
todos: DashboardTodo[];
};
export const useAdminDashboardOverview = (params: {
from: Date;
to: Date;
projectIds?: number[];
}) => {
const { from, to, projectIds } = params;
return useQuery({
queryKey: [
'/api/admin/dashboard/overview',
from.toISOString(),
to.toISOString(),
projectIds,
],
queryFn: async ({ signal }) => {
const { data } = await client.request<unknown, AdminDashboardOverview>({
method: 'get',
url: '/api/admin/dashboard/overview',
params: {
from: from.toISOString(),
to: to.toISOString(),
projectIds: projectIds?.join(','),
},
signal,
});
return data;
},
});
};
@@ -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 { useMemo } from 'react';
import dayjs from 'dayjs';
import minMax from 'dayjs/plugin/minMax';
import { CHART_FIVE_COLORS, getDayCount } from '@/shared';
dayjs.extend(minMax);
const getDarkColor = () => {
return (
'#' +
Array.from({ length: 6 })
.map(() => Math.floor(Math.random() * 16).toString(16))
.join('')
);
};
const useLineChartData = (
from: Date,
to: Date,
targetData: { id: number; name: string }[],
data?: {
id: number;
statistics: { startDate: string; endDate: string; count: number }[];
}[],
) => {
const dataKeys = useMemo(
() =>
targetData
.sort((a, b) => a.id - b.id)
.map(({ name }, i) => ({
color: CHART_FIVE_COLORS[i] ?? getDarkColor(),
name,
})),
[targetData],
);
const chartData = useMemo(() => {
if (!data) return [];
const result = [];
let currentDate = dayjs(to).endOf('day');
const startDate = dayjs(from).startOf('day');
const dayCount = getDayCount(from, to);
while (currentDate.isAfter(startDate)) {
const prevDate = dayjs.max(
currentDate.subtract(dayCount > 50 ? 6 : 0, 'day'),
dayjs(from),
);
const channelData = targetData.reduce(
(acc, cur) => {
const currentData = data.find((v) => v.id === cur.id);
const count =
currentData?.statistics.find(
(v) => v.endDate === currentDate.format('YYYY-MM-DD'),
)?.count ?? 0;
return { ...acc, [cur.name]: count };
},
{
date:
dayCount > 50 ?
`${prevDate.format('MM/DD')} - ${currentDate.format('MM/DD')}`
: currentDate.format('MM/DD'),
},
);
result.push(channelData);
currentDate = prevDate.subtract(1, 'day');
}
return result.reverse();
}, [data, targetData]);
return { chartData, dataKeys };
};
export default useLineChartData;
@@ -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 dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const CreateFeedbackPerIssueCard: React.FC<IProps> = (props) => {
const { from, to, projectId } = props;
const { t } = useTranslation();
const { data } = useOAIQuery({
path: '/api/admin/statistics/feedback/issued-ratio',
variables: { from: from.toISOString(), to: to.toISOString(), projectId },
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
return (
<DashboardCard
data={`${parseFloat(Math.abs((data?.ratio ?? 0) * 100).toFixed(1))}%`}
title={t('dashboard-card.issue-ratio.title')}
description={`${t('dashboard-card.issue-ratio.description', {
targetDate: `${dayjs(from).format('YYYY/MM/DD')} - ${dayjs(to).format(
'YYYY/MM/DD',
)}`,
})}`}
type="issue"
/>
);
};
export default CreateFeedbackPerIssueCard;
@@ -0,0 +1,144 @@
/**
* 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 dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import {
Combobox,
ComboboxContent,
ComboboxList,
ComboboxSelectItem,
ComboboxTrigger,
Icon,
} from '@ufb/react';
import {
ChartCard,
Legend,
LineChart,
useAllChannels,
useOAIQuery,
} from '@/shared';
import type { Channel } from '@/entities/channel';
import { useLineChartData } from '../lib';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const FeedbackLineChartWrapper: React.FC<IProps> = (props) => {
const { from, projectId, to } = props;
const { data: channels } = useAllChannels(projectId);
const { t } = useTranslation();
const [currentChannels, setCurrentChannels] = useState<Channel[]>([]);
const dayCount = useMemo(() => dayjs(to).diff(from, 'day') + 1, [from, to]);
useEffect(() => {
setCurrentChannels(channels?.items.slice(0, 5) ?? []);
}, [channels]);
const { data } = useOAIQuery({
path: '/api/admin/statistics/feedback',
variables: {
startDate: dayjs(from).startOf('day').format('YYYY-MM-DD'),
endDate: dayjs(to).endOf('day').format('YYYY-MM-DD'),
channelIds: currentChannels.map(({ id }) => id).join(','),
interval: dayCount > 50 ? 'week' : 'day',
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { chartData, dataKeys } = useLineChartData(
from,
to,
currentChannels,
data?.channels ?? [],
);
return (
<ChartCard
title={t('chart.feedback-trend.title')}
description={`${t('chart.feedback-trend.description')} (${dayjs(
from,
).format('YYYY/MM/DD')} - ${dayjs(to).format('YYYY/MM/DD')})`}
extra={
<div className="flex gap-3">
<Legend dataKeys={dataKeys} />
<ChannelSelectCombobox
currentChannels={currentChannels}
setCurrentChannels={setCurrentChannels}
channels={channels}
/>
</div>
}
>
<LineChart height={400} dataKeys={dataKeys} data={chartData} />
</ChartCard>
);
};
interface ChannelSelectComboboxProps {
channels?: { items: Channel[] };
currentChannels: Channel[];
setCurrentChannels: React.Dispatch<React.SetStateAction<Channel[]>>;
}
const ChannelSelectCombobox = (props: ChannelSelectComboboxProps) => {
const { channels, currentChannels, setCurrentChannels } = props;
return (
<Combobox>
<ComboboxTrigger>
<Icon name="RiFilter3Line" />
Filter
</ComboboxTrigger>
<ComboboxContent>
<ComboboxList maxHeight="200px">
{channels?.items.map((channel) => (
<ComboboxSelectItem
key={channel.id}
value={String(channel.id)}
checked={currentChannels.some(({ id }) => id === channel.id)}
onSelect={() => {
const isChecked = currentChannels.some(
({ id }) => id === channel.id,
);
setCurrentChannels((prev) =>
isChecked ? prev.filter(({ id }) => id !== channel.id)
: prev.length === 5 ? [...prev.slice(1), channel]
: [...prev, channel],
);
}}
>
{channel.name}
</ComboboxSelectItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};
export default FeedbackLineChartWrapper;
@@ -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.
*/
export { default as IssueRank } from './issue-rank.ui';
export { default as TotalFeedbackCard } from './total-feedback-card.ui';
export { default as TotalIssueCard } from './total-issue-card.ui';
export { default as CreateFeedbackPerIssueCard } from './create-feedback-per-issue-card.ui';
export { default as TodayFeedbackCard } from './today-feedback-card.ui';
export { default as YesterdayFeedbackCard } from './yesterday-feedback-card.ui';
export { default as SevenDaysFeedbackCard } from './seven-days-feedback-card.ui';
export { default as ThirtyDaysFeedbackCard } from './thirty-days-feedback-card.ui';
export { default as TodayIssueCard } from './today-issue-card.ui';
export { default as YesterdayIssueCard } from './yesterday-issue-card.ui';
export { default as SevenDaysIssueCard } from './seven-days-issue-card.ui';
export { default as ThirtyDaysIssueCard } from './thirty-days-issue-card.ui';
export { default as IssueLineChart } from './issue-line-chart.ui';
export { default as IssueBarChart } from './issue-bar-chart.ui';
export { default as IssueFeedbackLineChart } from './issue-feedback-line-chart.ui';
export { default as FeedbackLineChart } from './feedback-line-chart.ui';
@@ -0,0 +1,148 @@
/**
* 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 { useTranslation } from 'next-i18next';
import { ToggleGroup, ToggleGroupItem } from '@ufb/react';
import {
BarChart,
ChartCard,
ISSUES,
Path,
PieChart,
useOAIQuery,
} from '@/shared';
import type { IssueStatus } from '@/entities/issue';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const COLOR_MAP: Record<IssueStatus, string> = {
INIT: '#EAB308',
IN_PROGRESS: '#0EA5E9',
ON_REVIEW: '#22C55E',
DETAILED_REVIEW: '#F97316',
PENDING: '#6366F1',
RESOLVED: '#71717A',
};
const IssueBarChart: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const [type, setType] = useState('bar');
const { data: statisticsData } = useOAIQuery({
path: '/api/admin/statistics/issue/count-by-status',
variables: { projectId },
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const onChangeType = (type: 'bar' | 'pie') => (v: string) => {
setType(v === '' ? type : v);
};
const data = ISSUES(t).map(({ key, name }) => ({
name,
value: +(
statisticsData?.statistics.find((v) => v.status === key)?.count ?? 0
),
color: COLOR_MAP[key],
}));
const onClickIssue = (name: string | undefined) => {
if (!name) return;
const issue = ISSUES(t).find((v) => v.name === name);
window.open(
Path.ISSUE.replace('[projectId]', projectId.toString()) +
'?queries=' +
JSON.stringify([{ key: 'status', value: issue?.key, condition: 'IS' }]),
'_blank',
);
};
return (
<ChartCard
title={t('chart.issue-status-count.title')}
description={t('chart.issue-status-count.description')}
extra={
<ToggleGroup
type="single"
value={type}
onValueChange={onChangeType('pie')}
>
<ToggleGroupItem value="bar">Bar</ToggleGroupItem>
<ToggleGroupItem value="pie">Pie</ToggleGroupItem>
</ToggleGroup>
}
>
{type === 'pie' && (
<div className="flex items-center">
<PieChart data={data} onClick={onClickIssue} height={415} />
<IssueLegend data={data} />
</div>
)}
{type === 'bar' && (
<BarChart data={data} height={415} onClick={onClickIssue} />
)}
</ChartCard>
);
};
interface IssueLegendProps {
data?: { name: string; value: number; color: string }[];
}
const IssueLegend: React.FC<IssueLegendProps> = ({ data }) => {
return (
<div className="w-full">
<table className="table">
<thead>
<tr>
<th className="table-head !text-large-strong text-neutral-primary h-auto">
Status
</th>
<th className="table-head !text-large-strong text-neutral-primary h-auto">
Issue Count
</th>
</tr>
</thead>
<tbody>
{data?.map((item, index) => (
<tr key={`item-${index}`}>
<td className="!text-large-normal text-neutral-primary table-cell py-2">
<div className="flex items-center gap-2">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span>{item.name}</span>
</div>
</td>
<td className="!text-large-normal text-neutral-primary table-cell py-2">
{item.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default IssueBarChart;
@@ -0,0 +1,211 @@
/**
* 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 dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { useThrottle } from 'react-use';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
Icon,
toast,
} from '@ufb/react';
import {
ChartCard,
client,
commandFilter,
getDayCount,
InfiniteScrollArea,
Legend,
LineChart,
useOAIQuery,
} from '@/shared';
import type { Issue } from '@/entities/issue';
import { IssueBadge, useIssueSearchInfinite } from '@/entities/issue';
import { useLineChartData } from '../lib';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const IssueFeedbackLineChart: React.FC<IProps> = ({ from, projectId, to }) => {
const { t } = useTranslation();
const [searchName, setSearchName] = useState('');
const throttledSearchName = useThrottle(searchName, 1000);
const [currentIssues, setCurrentIssues] = useState<Issue[]>([]);
const { data } = useOAIQuery({
path: '/api/admin/statistics/feedback-issue',
variables: {
startDate: dayjs(from).startOf('day').format('YYYY-MM-DD'),
endDate: dayjs(to).endOf('day').format('YYYY-MM-DD'),
issueIds: currentIssues.map((issue) => issue.id).join(','),
interval: getDayCount(from, to) > 50 ? 'week' : 'day',
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
useEffect(() => {
void client
.post({
path: '/api/admin/projects/{projectId}/issues/search',
body: { sort: { feedbackCount: 'DESC' }, limit: 5 },
pathParams: { projectId },
})
.then(({ data }) => setCurrentIssues(data?.items ?? []));
}, [projectId]);
const { chartData, dataKeys } = useLineChartData(
from,
to,
currentIssues,
data?.issues.map((v) => ({
id: v.id,
statistics: v.statistics.map(({ startDate, endDate, feedbackCount }) => ({
startDate,
endDate,
count: feedbackCount,
})),
})),
);
const {
data: allIssueData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useIssueSearchInfinite(Number(projectId), {
limit: 10,
queries: [
{ key: 'name', value: throttledSearchName, condition: 'CONTAINS' },
],
sort: { name: 'ASC' },
});
const allIssues = useMemo(() => {
return allIssueData.pages
.map((v) => v?.items)
.filter((v) => !!v)
.flat();
}, [allIssueData]);
const handleIssueCheck = (issue: Issue) => {
if (currentIssues.length === 5) {
toast.error(t('popover.select-issue.max-issue-count'));
return;
}
setCurrentIssues((prev) => [...prev, issue]);
setSearchName('');
};
const handleIssueUncheck = (issue: Issue) => {
setCurrentIssues((prev) => prev.filter((v) => v.id !== issue.id));
};
return (
<ChartCard
title={t('chart.issue-comparison.title')}
description={`${t('chart.issue-comparison.description')} (${dayjs(
from,
).format('YYYY/MM/DD')} - ${dayjs(to).format('YYYY/MM/DD')})`}
extra={
<div className="flex gap-3">
<Legend dataKeys={dataKeys} />
<Combobox>
<ComboboxTrigger>
<Icon name="RiFilter3Line" />
Filter
</ComboboxTrigger>
<ComboboxContent options={{ filter: commandFilter }}>
<ComboboxInput
onValueChange={(value) => setSearchName(value)}
value={searchName}
/>
<ComboboxList maxHeight="200px">
<ComboboxEmpty>No results found.</ComboboxEmpty>
<ComboboxGroup
heading={
<span className="text-neutral-tertiary text-base-normal">
Selected Issue
</span>
}
>
{currentIssues.map((issue) => (
<ComboboxItem
key={issue.id}
onSelect={() => handleIssueUncheck(issue)}
>
<IssueBadge name={issue.name} status={issue.status} />
</ComboboxItem>
))}
</ComboboxGroup>
{allIssues.length > 0 && (
<ComboboxGroup
heading={
<span className="text-neutral-tertiary text-base-normal">
Issue List
</span>
}
>
{allIssues
.filter(
(v) =>
!currentIssues.some((issue) => issue.id === v.id),
)
.map((issue) => (
<ComboboxItem
key={issue.id}
value={issue.name}
onSelect={() => handleIssueCheck(issue)}
>
<IssueBadge name={issue.name} status={issue.status} />
</ComboboxItem>
))}
<InfiniteScrollArea
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
}
>
<LineChart height={400} dataKeys={dataKeys} data={chartData} />
</ChartCard>
);
};
export default IssueFeedbackLineChart;
@@ -0,0 +1,67 @@
/**
* 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 dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { ChartCard, getDayCount, LineChart, useOAIQuery } from '@/shared';
import { useLineChartData } from '../lib';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const IssueLineChart: React.FC<IProps> = ({ from, projectId, to }) => {
const { t } = useTranslation();
const { data } = useOAIQuery({
path: '/api/admin/statistics/issue/count-by-date',
variables: {
projectId,
startDate: dayjs(from).startOf('day').format('YYYY-MM-DD'),
endDate: dayjs(to).endOf('day').format('YYYY-MM-DD'),
interval: getDayCount(from, to) > 50 ? 'week' : 'day',
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { chartData, dataKeys } = useLineChartData(
from,
to,
[{ id: 1, name: '' }],
[{ id: 1, statistics: data?.statistics ?? [] }],
);
return (
<ChartCard
title={t('chart.issue-trend.title')}
description={`${t('chart.issue-trend.description')} (${dayjs(from).format(
'YYYY/MM/DD',
)} - ${dayjs(to).format('YYYY/MM/DD')})`}
>
<LineChart height={400} data={chartData} dataKeys={dataKeys} />
</ChartCard>
);
};
export default IssueLineChart;
@@ -0,0 +1,197 @@
/**
* 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 Link from 'next/link';
import { useRouter } from 'next/router';
import {
createColumnHelper,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useTranslation } from 'next-i18next';
import {
Combobox,
ComboboxContent,
ComboboxList,
ComboboxSelectItem,
ComboboxTrigger,
Icon,
} from '@ufb/react';
import { ISSUES, Path } from '@/shared';
import { DashboardTable } from '@/shared/ui';
import type { IssueStatus } from '@/entities/issue';
import { IssueBadge, useIssueSearch } from '@/entities/issue';
interface IssueTableData {
id: number;
no: number;
status: IssueStatus;
name: string;
count: number;
}
const columnHelper = createColumnHelper<IssueTableData>();
const columns = [
columnHelper.accessor('no', {
header: 'No',
enableSorting: false,
size: 30,
}),
columnHelper.accessor('name', {
header: 'Issue',
enableSorting: false,
cell({ getValue, row }) {
const router = useRouter();
return (
<>
<span>{getValue()}</span>
<Link
href={{
pathname: Path.ISSUE,
query: {
projectId: router.query.projectId,
queries: JSON.stringify([
{ key: 'name', value: row.original.name, condition: 'IS' },
]),
},
}}
target="_blank"
rel="noreferrer"
className="ml-1"
>
<Icon
name="RiExternalLinkFill"
size={16}
className="cursor-pointer"
/>
</Link>
</>
);
},
}),
columnHelper.accessor('status', {
header: 'Status',
enableSorting: false,
cell({ getValue }) {
return <IssueBadge status={getValue()} />;
},
}),
columnHelper.accessor('count', {
header: 'Feedback Count',
cell: ({ getValue }) => <p>{getValue().toLocaleString()}</p>,
enableSorting: true,
}),
];
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const limitOptions = [
{ label: 'Top 5', value: '5' },
{ label: 'Top 10', value: '10' },
{ label: 'Top 15', value: '15' },
{ label: 'Top 20', value: '20' },
];
const IssueRank: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const issues = useMemo(() => ISSUES(t), [t]);
const [limit, setLimit] = useState(Number(limitOptions[0]?.value ?? 0));
const [currentIssueStatusList, setCurrentIssueStatusList] = useState(issues);
const { data } = useIssueSearch(projectId, {
sort: { feedbackCount: 'DESC' },
limit,
queries: currentIssueStatusList.map((v) => ({
key: 'status',
value: v.key,
condition: 'IS',
})),
operator: 'OR',
});
const newData = useMemo(
() =>
data?.items.map((item, i) => ({
id: item.id,
no: i + 1,
count: item.feedbackCount,
name: item.name,
status: item.status,
})) ?? [],
[data, t],
);
const table = useReactTable({
columns,
data: newData,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<DashboardTable
title={t('chart.issue-rank.title')}
description={t('chart.issue-rank.description')}
table={table}
selectData={{
options: limitOptions,
value: String(limit),
onChange: (v) => setLimit(Number(v)),
}}
filterContent={
<Combobox>
<ComboboxTrigger>
<Icon name="RiFilter3Line" />
Filter
</ComboboxTrigger>
<ComboboxContent>
<ComboboxList>
{issues.map((issue) => (
<ComboboxSelectItem
key={issue.key}
checked={currentIssueStatusList.some(
({ key }) => key === issue.key,
)}
onSelect={() => {
const isChecked = currentIssueStatusList.some(
({ key }) => key === issue.key,
);
setCurrentIssueStatusList((prev) =>
isChecked ?
prev.filter((v) => v.key !== issue.key)
: [...prev, issue],
);
}}
>
{issue.name}
</ComboboxSelectItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
}
/>
);
};
export default IssueRank;
@@ -0,0 +1,88 @@
/**
* 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 } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const SevenDaysFeedbackCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(7, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(14, 'day').startOf('day').toISOString(),
to: dayjs().subtract(8, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="feedback"
data={currentData?.count ?? 0}
title={t('dashboard-card.n-days-feedback.title', { n: 7 })}
description={t('dashboard-card.n-days-feedback.description', {
n: 7,
targetDate: `${dayjs()
.subtract(7, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(1, 'day')
.format('YYYY/MM/DD')}`,
compareDate: `${dayjs()
.subtract(14, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(8, 'day')
.format('YYYY/MM/DD')}`,
})}
percentage={percentage}
/>
);
};
export default SevenDaysFeedbackCard;
@@ -0,0 +1,87 @@
/**
* 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 } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const SevenDaysIssueCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(7, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(14, 'day').startOf('day').toISOString(),
to: dayjs().subtract(8, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="issue"
data={currentData?.count ?? 0}
title={t('dashboard-card.n-days-issue.title', { n: 7 })}
description={t('dashboard-card.n-days-issue.description', {
n: 7,
targetDate: `${dayjs()
.subtract(7, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(1, 'day')
.format('YYYY/MM/DD')}`,
compareDate: `${dayjs()
.subtract(14, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(8, 'day')
.format('YYYY/MM/DD')}`,
})}
percentage={percentage}
/>
);
};
export default SevenDaysIssueCard;
@@ -0,0 +1,88 @@
/**
* 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 } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const ThirtyDaysFeedbackCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(30, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(60, 'day').startOf('day').toISOString(),
to: dayjs().subtract(31, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="feedback"
data={currentData?.count ?? 0}
title={t('dashboard-card.n-days-feedback.title', { n: 30 })}
description={t('dashboard-card.n-days-feedback.description', {
n: 30,
targetDate: `${dayjs()
.subtract(30, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(1, 'day')
.format('YYYY/MM/DD')}`,
compareDate: `${dayjs()
.subtract(60, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(31, 'day')
.format('YYYY/MM/DD')}`,
})}
percentage={percentage}
/>
);
};
export default ThirtyDaysFeedbackCard;
@@ -0,0 +1,90 @@
/**
* 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 } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const ThirtyDaysIssueCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(30, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(60, 'day').startOf('day').toISOString(),
to: dayjs().subtract(31, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="issue"
data={currentData?.count ?? 0}
title={t('dashboard-card.n-days-issue.title', { n: 30 })}
description={t('dashboard-card.n-days-issue.description', {
n: 30,
targetDate: `${dayjs()
.subtract(30, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(1, 'day')
.format('YYYY/MM/DD')}`,
compareDate: `${dayjs()
.subtract(60, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(31, 'day')
.format('YYYY/MM/DD')}`,
})}
percentage={percentage}
/>
);
};
export default ThirtyDaysIssueCard;
@@ -0,0 +1,80 @@
/**
* 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 } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const TodayFeedbackCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().startOf('day').toISOString(),
to: dayjs().endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="feedback"
data={(currentData?.count ?? 0).toLocaleString()}
title={t('dashboard-card.today-feedback.title')}
description={t('dashboard-card.today-feedback.description', {
targetDate: dayjs().format('YYYY/MM/DD'),
compareDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
})}
percentage={percentage}
/>
);
};
export default TodayFeedbackCard;
@@ -0,0 +1,80 @@
/**
* 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 } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const TodayIssueCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().startOf('day').toISOString(),
to: dayjs().endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="issue"
data={currentData?.count ?? 0}
title={t('dashboard-card.today-issue.title')}
description={t('dashboard-card.today-issue.description', {
targetDate: dayjs().format('YYYY/MM/DD'),
compareDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
})}
percentage={percentage}
/>
);
};
export default TodayIssueCard;
@@ -0,0 +1,59 @@
/**
* 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 dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const TotalFeedbackCard: React.FC<IProps> = ({ projectId, from, to }) => {
const { t } = useTranslation();
const { data } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs(from).startOf('day').toISOString(),
to: dayjs(to).endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
return (
<DashboardCard
type="feedback"
data={data?.count ?? 0}
title={t('dashboard-card.total-feedback.title')}
description={t('dashboard-card.total-feedback.description', {
targetDate: `${dayjs(from).format('YYYY/MM/DD')} - ${dayjs(to).format(
'YYYY/MM/DD',
)}`,
})}
/>
);
};
export default TotalFeedbackCard;
@@ -0,0 +1,59 @@
/**
* 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 dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const TotalIssueCard: React.FC<IProps> = ({ from, to, projectId }) => {
const { t } = useTranslation();
const { data } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs(from).startOf('day').toISOString(),
to: dayjs(to).endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
return (
<DashboardCard
type="issue"
data={data?.count ?? 0}
title={t('dashboard-card.total-issue.title')}
description={t('dashboard-card.total-issue.description', {
targetDate: `${dayjs(from).format('YYYY/MM/DD')} - ${dayjs(to).format(
'YYYY/MM/DD',
)}`,
})}
/>
);
};
export default TotalIssueCard;
@@ -0,0 +1,79 @@
/**
* 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 } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const YesterdayFeedbackCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(2, 'day').startOf('day').toISOString(),
to: dayjs().subtract(2, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="feedback"
data={currentData?.count ?? 0}
title={t('dashboard-card.yesterday-feedback.title')}
description={t('dashboard-card.yesterday-feedback.description', {
targetDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
compareDate: dayjs().subtract(2, 'day').format('YYYY/MM/DD'),
})}
percentage={percentage}
/>
);
};
export default YesterdayFeedbackCard;
@@ -0,0 +1,79 @@
/**
* 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 } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const YesterdayIssueCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(2, 'day').startOf('day').toISOString(),
to: dayjs().subtract(2, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="issue"
data={currentData?.count ?? 0}
title={t('dashboard-card.yesterday-issue.title')}
description={t('dashboard-card.yesterday-issue.description', {
targetDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
compareDate: dayjs().subtract(2, 'day').format('YYYY/MM/DD'),
})}
percentage={percentage}
/>
);
};
export default YesterdayIssueCard;
@@ -0,0 +1,90 @@
/**
* 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 { FieldInfo } from '@/entities/field';
export const FEEDBACK_PRIORITY_FIELD_KEY = 'priority';
export const FEEDBACK_ASSIGNEE_FIELD_KEY = 'assignee';
export const ensureFeedbackAssigneeField = <T extends FieldInfo>(
fields: T[],
): T[] => {
if (fields.some((field) => field.key === FEEDBACK_ASSIGNEE_FIELD_KEY)) {
return fields;
}
const order =
fields.reduce((max, field) => Math.max(max, field.order), -1) + 1;
return fields.concat({
id: 0,
createdAt: '',
updatedAt: '',
key: FEEDBACK_ASSIGNEE_FIELD_KEY,
name: '담당자',
description: '관리자가 지정한 피드백 담당자입니다.',
format: 'text',
property: 'READ_ONLY',
status: 'ACTIVE',
order,
options: [],
aiFieldTemplateId: null,
aiFieldTargetKeys: null,
aiFieldAutoProcessing: null,
} as unknown as T);
};
export const FEEDBACK_PRIORITY_OPTIONS = [
{ key: 'LOW', name: '낮음', colorClassName: 'bg-zinc-500' },
{ key: 'MEDIUM', name: '보통', colorClassName: 'bg-sky-500' },
{ key: 'HIGH', name: '높음', colorClassName: 'bg-orange-500' },
{ key: 'CRITICAL', name: '긴급', colorClassName: 'bg-red-500' },
] as const;
export const getFeedbackPriorityOption = (value?: string | null) =>
FEEDBACK_PRIORITY_OPTIONS.find((option) => option.key === value) ??
FEEDBACK_PRIORITY_OPTIONS[1];
export const ensureFeedbackPriorityField = <T extends FieldInfo>(
fields: T[],
): T[] => {
if (fields.some((field) => field.key === FEEDBACK_PRIORITY_FIELD_KEY)) {
return fields;
}
const order =
fields.reduce((maxOrder, field) => Math.max(maxOrder, field.order), -1) + 1;
return fields.concat({
id: 0,
createdAt: '',
updatedAt: '',
key: FEEDBACK_PRIORITY_FIELD_KEY,
name: '중요도',
description: '관리자가 지정하는 피드백 중요도입니다.',
format: 'select',
property: 'EDITABLE',
status: 'ACTIVE',
order,
options: FEEDBACK_PRIORITY_OPTIONS.map(({ key, name }, index) => ({
key,
name,
id: index + 1,
})),
aiFieldTemplateId: null,
aiFieldTargetKeys: null,
aiFieldAutoProcessing: null,
} as unknown as T);
};
@@ -0,0 +1,59 @@
/**
* 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.
*/
export type FeedbackStatus =
| 'INIT'
| 'ON_REVIEW'
| 'DETAILED_REVIEW'
| 'IN_PROGRESS'
| 'RESOLVED'
| 'PENDING';
export type FeedbackStatusOption = {
value: FeedbackStatus;
label: string;
colorClassName: string;
};
export const FEEDBACK_STATUS_OPTIONS: FeedbackStatusOption[] = [
{ value: 'INIT', label: '신규', colorClassName: 'bg-yellow-500' },
{ value: 'ON_REVIEW', label: '검토중', colorClassName: 'bg-green-500' },
{
value: 'DETAILED_REVIEW',
label: '정밀검토중',
colorClassName: 'bg-orange-500',
},
{
value: 'IN_PROGRESS',
label: '진행중',
colorClassName: 'bg-sky-500',
},
{ value: 'RESOLVED', label: '완료', colorClassName: 'bg-zinc-500' },
{ value: 'PENDING', label: '진행하지 않음', colorClassName: 'bg-indigo-500' },
];
export const getFeedbackStatusOption = (status?: string | null) => {
const normalizedStatus =
status === 'NEW' ? 'INIT'
: status === 'IN_REVIEW' ? 'ON_REVIEW'
: status === 'ANSWERED' || status === 'CLOSED' ? 'RESOLVED'
: status;
return (
FEEDBACK_STATUS_OPTIONS.find(
(option) => option.value === normalizedStatus,
) ?? { value: 'INIT', label: '신규', colorClassName: 'bg-yellow-500' }
);
};
@@ -0,0 +1,220 @@
/**
* 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 { ColumnDef, DisplayColumnDef } from '@tanstack/react-table';
import { createColumnHelper } from '@tanstack/react-table';
import { Badge, Icon } from '@ufb/react';
import { cn, ExpandableText, TableCheckbox } from '@/shared';
import { FieldFormatLabel } from '@/entities/field';
import type { FieldInfo } from '@/entities/field';
import { FIELD_FORMAT_ICON_MAP } from '../field/field.constant';
import type { Issue } from '../issue';
import { getFeedbackStatusOption } from './feedback-status';
import type { Feedback } from './feedback.type';
import FeedbackCell from './ui/feedback-cell';
import IssueCell from './ui/issue-cell';
const columnHelper = createColumnHelper<Feedback>();
const FEEDBACK_FIELD_ORDER = [
'id',
'priority',
'title',
'contents',
'issues',
'assignee',
'createdAt',
'updatedAt',
];
const FEEDBACK_FIELD_WIDTHS: Record<string, number> = {
id: 80,
priority: 120,
title: 240,
contents: 320,
issues: 200,
assignee: 180,
createdAt: 180,
updatedAt: 180,
};
const feedbackStatusColumn = (
feedbackStatuses: Record<string, string>,
): DisplayColumnDef<Feedback> =>
columnHelper.display({
id: 'feedback-status',
size: 150,
minSize: 120,
header: () => (
<div className="flex items-center gap-1">
<Icon name="RiCheckboxCircleLine" size={16} />
Status
</div>
),
cell: ({ row }) => {
const option = getFeedbackStatusOption(
feedbackStatuses[String(row.original.id)],
);
return <Badge className={option.colorClassName}>{option.label}</Badge>;
},
enableSorting: false,
});
export const getColumns = (
fieldData: FieldInfo[],
isPreview?: boolean,
feedbackStatuses: Record<string, string> = {},
assigneeByFeedbackId: Record<string, string> = {},
): ColumnDef<Feedback>[] => {
const checkboxColumn = columnHelper.display({
id: 'feedback-checkbox',
header: ({ table }) => (
<TableCheckbox
{...{
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onCheckedChange: (checked) => table.toggleAllRowsSelected(checked),
}}
/>
),
cell: ({ row }) => (
<TableCheckbox
{...{
checked: row.getIsSelected(),
disabled: !row.getCanSelect(),
indeterminate: row.getIsSomeSelected(),
onCheckedChange: (checked) => row.toggleSelected(checked),
}}
/>
),
size: 50,
enableResizing: false,
});
const orderedFields = [...fieldData].sort((a, b) => {
const aIndex = FEEDBACK_FIELD_ORDER.indexOf(a.key);
const bIndex = FEEDBACK_FIELD_ORDER.indexOf(b.key);
const normalizedAIndex =
aIndex === -1 ? FEEDBACK_FIELD_ORDER.length : aIndex;
const normalizedBIndex =
bIndex === -1 ? FEEDBACK_FIELD_ORDER.length : bIndex;
return normalizedAIndex - normalizedBIndex;
});
const fieldColumns = orderedFields.map((field) =>
field.key === 'id' ?
columnHelper.accessor('id', {
id: 'id',
size: FEEDBACK_FIELD_WIDTHS.id,
minSize: FEEDBACK_FIELD_WIDTHS.id,
header: () => (
<div className="flex items-center gap-1">
<Icon name={FIELD_FORMAT_ICON_MAP.number} size={16} /># ID
</div>
),
cell: (info) => (
<ExpandableText isExpanded={info.row.getIsExpanded()}>
{info.getValue() as number}
</ExpandableText>
),
enableSorting: false,
})
: field.key === 'issues' ?
columnHelper.accessor('issues', {
id: 'issues',
size: FEEDBACK_FIELD_WIDTHS.issues,
minSize: 150,
header: () => <FieldFormatLabel format="multiSelect" name="Issue" />,
cell: ({ getValue, row }) => (
<div className={cn({ 'overflow-hidden': !row.getIsExpanded() })}>
<div className={cn('flex', { 'w-max': !row.getIsExpanded() })}>
<IssueCell
issues={getValue() as Issue[]}
feedbackId={Number(row.id)}
isPreview={isPreview}
showSameIssueFeedbackLink={!isPreview}
/>
</div>
</div>
),
enableSorting: false,
})
: field.key === 'assignee' ?
columnHelper.accessor(
(row) => assigneeByFeedbackId[String(row.id)] ?? '',
{
id: 'assignee',
size: FEEDBACK_FIELD_WIDTHS.assignee,
minSize: 120,
header: () => <FieldFormatLabel format="text" name={field.name} />,
cell: ({ row }) => (
<ExpandableText isExpanded={row.getIsExpanded()}>
{assigneeByFeedbackId[String(row.original.id)] ?? '-'}
</ExpandableText>
),
enableSorting: false,
},
)
: columnHelper.accessor(field.key, {
id: field.key,
size:
FEEDBACK_FIELD_WIDTHS[field.key] ??
(field.format === 'text' ? 200 : 150),
minSize: 75,
header: () => (
<FieldFormatLabel format={field.format} name={field.name} />
),
cell: (info) => (
<FeedbackCell
field={field}
isExpanded={info.row.getIsExpanded()}
value={info.getValue()}
feedbackId={Number(info.row.id)}
/>
),
meta: {
truncate: !(
field.format === 'select' ||
field.format === 'multiSelect' ||
field.format === 'images'
),
},
enableSorting:
field.format === 'date' &&
(field.key === 'createdAt' || field.key === 'updatedAt'),
}),
) as DisplayColumnDef<Feedback>[];
const columns: ColumnDef<Feedback>[] = [checkboxColumn, ...fieldColumns];
if (!isPreview) {
const issueColumnIndex = columns.findIndex(
(column) => column.id === 'issues',
);
const statusColumnIndex =
issueColumnIndex === -1 ? columns.length : issueColumnIndex + 1;
columns.splice(
statusColumnIndex,
0,
feedbackStatusColumn(feedbackStatuses),
);
}
return columns;
};
@@ -0,0 +1,17 @@
/**
* 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.
*/
export type Feedback = Record<string, unknown>;
+19
View File
@@ -0,0 +1,19 @@
/**
* 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.
*/
export * from './lib';
export * from './ui';
export * from './feedback.type';
export * from './feedback-priority';
@@ -0,0 +1,19 @@
/**
* 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.
*/
export { default as useFeedbackSearch } from './use-feedback-search';
export { default as usePreviewFeedback } from './use-preview-feedback';
export { default as useFeedbackQueryConverter } from './use-feedback-query-converter';
export { default as useAIFIeldFeedbackCellLoading } from './use-ai-field-feedback-cell-loading';
@@ -0,0 +1,25 @@
/**
* 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 { create } from 'zustand';
const useAIFIeldFeedbackCellLoading = create<{
loadingFeedbackIds: Set<number>;
setLoadingFeedbackIds: (ids: number[]) => void;
}>((set) => ({
loadingFeedbackIds: new Set(),
setLoadingFeedbackIds: (ids) => set({ loadingFeedbackIds: new Set(ids) }),
}));
export default useAIFIeldFeedbackCellLoading;
@@ -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 type { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import type { IFetchError, OAIPathParameters, OAIRequestBody } from '@/shared';
import { client } from '@/shared';
type IPath = OAIPathParameters<
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/export',
'post'
>;
type IBody = OAIRequestBody<
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/export',
'post'
>;
interface IInput {
params: IPath;
options?: Omit<
UseMutationOptions<void, IFetchError, IBody, unknown>,
'mutationKey' | 'mutationFn'
>;
}
const useFeedbackDownload = ({ params, options }: IInput) => {
return useMutation({
mutationFn: async (body: IBody) => {
const { data, headers } = await client.post({
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/export',
pathParams: params,
body,
config: { responseType: 'arraybuffer' },
});
const filename = ((headers['content-disposition'] ?? '') as string).split(
'filename=',
)[1];
if (!filename || typeof data !== 'object') return;
const decodedFilename = decodeURI(filename.slice(1, filename.length - 1));
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', decodedFilename);
document.body.appendChild(link);
link.click();
link.remove();
},
...options,
});
};
export default useFeedbackDownload;
@@ -0,0 +1,235 @@
/**
* 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 { useCallback, useEffect, useMemo } from 'react';
import dayjs from 'dayjs';
import { createParser, parseAsStringLiteral, useQueryState } from 'nuqs';
import type {
DateRangeType,
SearchQuery,
TableFilter,
TableFilterField,
TableFilterFieldFotmat,
TableFilterOperator,
} from '@/shared';
import { client, TableFilterOperators } from '@/shared';
const toQuery = (
projectId: number,
): Record<TableFilterFieldFotmat, (valeu: unknown) => unknown> => ({
number: (value) => Number(value),
issue: async (value) => {
if (typeof value === 'string') {
const { data } = await client.post({
path: '/api/admin/projects/{projectId}/issues/search',
pathParams: { projectId },
body: {
queries: [{ key: 'name', value, condition: 'IS' }] as SearchQuery[],
},
});
const result = data?.items.find((v) => v.name === value);
return result ? [result.id] : [];
}
if (Array.isArray(value)) {
return await Promise.all(
value.map(async (v: string) => {
const issueId = parseInt(v);
if (!isNaN(issueId)) return issueId;
const { data } = await client.post({
path: '/api/admin/projects/{projectId}/issues/search',
pathParams: { projectId },
body: {
queries: [
{ key: 'name', value: v, condition: 'IS' },
] as SearchQuery[],
},
});
return data?.items.find((vv) => vv.name === v)?.id;
}),
);
}
},
string: (value) => value,
select: (value) => value,
date: (value) => value,
ticket: (value) => value,
multiSelect: (value) => value,
});
const DEFAULT_DATE_RANGE = (
feedbackSearchMaxDays: number,
): SearchQuery | null =>
feedbackSearchMaxDays === -1 ? null : (
{
key: 'createdAt',
value: {
gte: dayjs()
.subtract(feedbackSearchMaxDays - 1, 'day')
.startOf('day')
.toDate(),
lt: dayjs().endOf('day').toDate(),
},
condition: 'BETWEEN',
}
);
const useFeedbackQueryConverter = (input: {
projectId: number;
filterFields: TableFilterField[];
feedbackSearchMaxDays: number;
}) => {
const { projectId, filterFields, feedbackSearchMaxDays } = input;
const defaultDateRange = DEFAULT_DATE_RANGE(feedbackSearchMaxDays);
const [operator, setOperator] = useQueryState<TableFilterOperator>(
'operator',
parseAsStringLiteral(TableFilterOperators).withDefault('AND'),
);
const [queries, setQueries] = useQueryState<SearchQuery[]>(
'queries',
createParser({
parse: (value) => JSON.parse(value) as SearchQuery[],
serialize: (value) => JSON.stringify(value),
}).withDefault([]),
);
const [defaultQueries, setDefaultQueries] = useQueryState<SearchQuery[]>(
'defaultQueries',
createParser({
parse: (value) => JSON.parse(value) as SearchQuery[],
serialize: (value) => JSON.stringify(value),
}).withDefault(defaultDateRange ? [defaultDateRange] : []),
);
const dateRange = useMemo(() => {
const dateQuery = defaultQueries.find((v) => v.key === 'createdAt');
const createdAt = dateQuery?.value as
| { gte: string; lt: string }
| undefined;
return createdAt ?
{
startDate: dayjs(createdAt.gte).toDate(),
endDate: dayjs(createdAt.lt).toDate(),
}
: null;
}, [defaultQueries]);
const onChangeDateRange = useCallback(async (value: DateRangeType) => {
if (!value) {
await setDefaultQueries([]);
return;
}
await setDefaultQueries([
{
key: 'createdAt',
value: {
gte: dayjs(value.startDate).toISOString(),
lt: dayjs(value.endDate).toISOString(),
},
condition: 'BETWEEN',
},
]);
}, []);
const tableFilters = useMemo(() => {
return queries
.map(({ key, value, condition }) => {
const field = filterFields.find((v) => v.key === key);
if (!field) return null;
const { format, name } = field;
return { key, name, value, format, condition };
})
.filter((v) => !!v) as TableFilter[];
}, [queries, filterFields]);
const onChangeTableFilters = useCallback(
async (tableFilters: TableFilter[], operator: TableFilterOperator) => {
const result = await Promise.all(
tableFilters.map(async ({ condition, format, key, value }) => ({
key,
value: await toQuery(projectId)[format](value),
condition,
})),
);
if (result.length === 0 && !dateRange) {
await setDefaultQueries(defaultDateRange ? [defaultDateRange] : []);
await setQueries([]);
await setOperator('AND');
return;
}
await setOperator(operator);
await setQueries(result);
},
[dateRange],
);
const filteredQueries = queries.filter((query) =>
filterFields.some((field) => field.key === query.key),
);
const filteredTableFilters = tableFilters.filter((v) =>
filterFields.some((vv) => vv.key === v.key),
);
useEffect(() => {
const createdAtQuery = defaultQueries.find((query) => query.key === 'createdAt');
if (feedbackSearchMaxDays === -1) {
if (createdAtQuery) {
void setDefaultQueries([]);
}
return;
}
if (!createdAtQuery && defaultDateRange) {
void setDefaultQueries([defaultDateRange]);
return;
}
const diff = dayjs(dateRange?.endDate).diff(
dayjs(dateRange?.startDate),
'day',
);
if (diff >= feedbackSearchMaxDays) {
void onChangeDateRange({
startDate: dayjs()
.subtract(feedbackSearchMaxDays - 1, 'day')
.toDate(),
endDate: dayjs().toDate(),
});
}
}, [defaultDateRange, defaultQueries, feedbackSearchMaxDays]);
return {
queries: filteredQueries,
defaultQueries,
tableFilters: filteredTableFilters,
operator,
dateRange,
updateTableFilters: onChangeTableFilters,
updateDateRange: onChangeDateRange,
};
};
export default useFeedbackQueryConverter;
@@ -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 type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type {
IFetchError,
OAIMutationResponse,
OAIRequestBody,
SearchQuery,
} from '@/shared';
import { client } from '@/shared';
type TData = OAIMutationResponse<
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
'post'
>;
interface IBody
extends Omit<
OAIRequestBody<
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
'post'
>,
'queries'
> {
queries: SearchQuery[];
}
const useFeedbackSearch = (
projectId: number,
channelId: number,
body: IBody,
options?: Omit<UseQueryOptions<TData, IFetchError>, 'queryKey' | 'queryFn'>,
) => {
return useQuery<TData, IFetchError>({
queryKey: [
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
projectId,
channelId,
body,
],
queryFn: async () => {
const { data: result } = await client.post({
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
pathParams: { projectId, channelId },
body,
});
return result;
},
...options,
});
};
export default useFeedbackSearch;
@@ -0,0 +1,102 @@
/**
* 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 } from 'react';
import { faker } from '@faker-js/faker';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { ISSUES } from '@/shared';
import type { Category } from '@/entities/category';
import type { FieldInfo } from '@/entities/field';
import type { Issue } from '@/entities/issue';
import type { Feedback } from '../feedback.type';
const usePreviewFeedback = (fields: FieldInfo[]) => {
const { t } = useTranslation();
const feedbacks = useMemo(() => {
const feedbacks: Feedback[] = [];
const categories: Category[] = new Array(5).fill(0).map((_, i) => ({
id: i,
name: faker.word.sample(),
}));
const issues: Issue[] = faker.helpers
.uniqueArray(() => faker.word.sample(), 10)
.map((v) => ({
id: faker.number.int(),
createdAt: faker.date.recent().toString(),
description: faker.lorem.sentence(),
feedbackCount: faker.number.int(),
updatedAt: faker.date.recent().toString(),
name: v,
status: faker.helpers.arrayElement(ISSUES(t).map((v) => v.key)),
category: faker.helpers.arrayElement(categories),
}));
for (let i = 1; i <= 10; i++) {
const fakeData: Feedback = {
id: i,
createdAt: dayjs().add(i, 'hour').toString(),
updatedAt: dayjs().add(i, 'hour').toString(),
issues: faker.helpers.arrayElements(issues, {
min: 0,
max: 4,
}),
};
for (const field of fields) {
if (
field.key === 'id' ||
field.key === 'createdAt' ||
field.key === 'updatedAt' ||
field.key === 'issues'
) {
continue;
}
fakeData[field.key] =
field.format === 'date' ? faker.date.anytime()
: field.format === 'keyword' ? faker.word.noun()
: field.format === 'multiSelect' ?
faker.helpers.arrayElements(
(field.options ?? []).map((v) => v.name),
)
: field.format === 'select' ?
(field.options ?? []).length > 0 ?
faker.helpers.arrayElement(
(field.options ?? []).map((v) => v.name),
)
: undefined
: field.format === 'number' ? faker.number.int()
: field.format === 'text' ? faker.lorem.text()
: field.format === 'aiField' ?
{
status: 'success',
message: 'The AI response results will be displayed here.',
}
: faker.helpers.arrayElements(
Array.from({
length: 15,
}).fill('/assets/images/sample_image.png'),
);
}
feedbacks.push(fakeData);
}
return feedbacks;
}, [fields]);
return feedbacks;
};
export default usePreviewFeedback;
@@ -0,0 +1,233 @@
/**
* 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 { useTranslation } from 'next-i18next';
import { Badge, Button, ComboboxGroup, ComboboxItem, Icon } from '@ufb/react';
import type { TableFilterCondition } from '@/shared';
import {
client,
cn,
GRADIENT_CSS,
useOAIMutation,
useOAIQuery,
} from '@/shared';
import type { Issue, IssueStatus } from '@/entities/issue';
interface Props {
projectId: number;
channelId: number;
feedbackId: number;
onSelect?: (issueItem: AIIssueRecommendationItem) => void;
currentIssues: Issue[];
}
type AIIssueRecommendationItem = {
id?: number;
name: string;
option: 'ADDED' | 'CREATE' | 'EXISTING';
status?: IssueStatus;
};
const AiIssueComboboxGroup = ({
projectId,
channelId,
feedbackId,
onSelect,
currentIssues,
}: Props) => {
const { t } = useTranslation();
const [issues, setIssues] = useState<AIIssueRecommendationItem[]>([]);
const [searchedIssues, setSearchedIssues] = useState<Issue[]>([]);
const [status, setStatus] = useState<
'success' | 'error' | 'pending' | 'idle'
>('idle');
const [errorMessage, setErrorMessage] = useState('');
const { data: aiIntegrations } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/integrations',
variables: { projectId },
});
const { data: aiIssueTemplates } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/issueTemplates',
variables: { projectId },
});
const { mutateAsync: recommendIssues, isPending } = useOAIMutation({
method: 'post',
path: '/api/admin/projects/{projectId}/ai/issueRecommend/{feedbackId}',
pathParams: { projectId, feedbackId },
queryOptions: {
async onSuccess(data) {
if (!data) return;
if (data.success) {
setStatus('success');
await getIssueRecommendationItems(data.result);
}
if (!data.success) {
setStatus('error');
setErrorMessage(data.message);
}
},
},
});
const getIssueRecommendationItems = async (
input: { issueName: string }[],
) => {
const issueNames = input.map((v) => v.issueName);
const { data: searchedIssuesResponse } = await client.post({
path: '/api/admin/projects/{projectId}/issues/search',
pathParams: { projectId },
body: {
operator: 'OR',
queries: issueNames.map((name) => ({
key: 'name',
condition: 'IS' as TableFilterCondition,
value: name,
})),
},
});
const searchedIssuesList = searchedIssuesResponse?.items ?? [];
setSearchedIssues(searchedIssuesList);
const result: AIIssueRecommendationItem[] = input.map(({ issueName }) => {
const existingIssue = currentIssues.find(
({ name }) => name === issueName,
);
if (existingIssue) {
return {
id: existingIssue.id,
name: issueName,
option: 'ADDED',
status: existingIssue.status,
};
}
const matchedIssue = searchedIssuesList.find(
({ name }) => name === issueName,
);
if (matchedIssue) {
return {
id: matchedIssue.id,
name: issueName,
option: 'EXISTING',
status: matchedIssue.status,
};
}
return { name: issueName, option: 'CREATE' };
});
setIssues(result);
};
useEffect(() => {
setIssues((prev) =>
prev.map((issue) => {
if (currentIssues.some(({ name }) => name === issue.name)) {
return { ...issue, option: 'ADDED' };
}
if (searchedIssues.some(({ name }) => name === issue.name)) {
return { ...issue, option: 'EXISTING' };
}
return { ...issue, option: 'CREATE' };
}),
);
}, [currentIssues, searchedIssues]);
const aiIssue = aiIssueTemplates?.find((v) => v.channelId === channelId);
if (!aiIntegrations?.apiKey) {
return <></>;
}
return (
<ComboboxGroup
className="border-neutral-tertiary border-b"
heading={
status === 'success' && (
<div className="flex items-center justify-between">
<span className="text-neutral-tertiary text-base-normal">
AI Recommend
</span>
<p
className="text-small-normal cursor-pointer"
onClick={() => recommendIssues(undefined)}
>
Retry
</p>
</div>
)
}
>
{status !== 'success' && (
<div className="px-2 py-1">
<Button
size="small"
className="w-full"
onClick={() => recommendIssues(undefined)}
style={GRADIENT_CSS.primary}
disabled={!aiIssue?.isEnabled}
loading={isPending}
>
<Icon name="RiSparklingFill" />
Run AI
</Button>
</div>
)}
<p className="text-neutral-tertiary text-small-normal px-2 py-1 text-center">
{!aiIssue?.isEnabled ?
t('v2.description.ai-issue-recommendation-setting-required')
: status === 'idle' ?
t('v2.text.ai-issue-ready')
: status === 'pending' ?
t('v2.text.ai-issue-pending')
: ''}
</p>
{status === 'success' &&
issues.map((issue) => (
<ComboboxItem
key={issue.name}
value={issue.name}
onSelect={() => onSelect?.(issue)}
className={cn('group justify-between gap-4', {
'cursor-not-allowed opacity-50': issue.option === 'ADDED',
})}
disabled={issue.option === 'ADDED'}
>
<Badge style={GRADIENT_CSS.primary}>{issue.name}</Badge>
{issue.option === 'ADDED' ?
<Icon name="RiCheckLine" size={16} />
: <span className="text-small-normal text-neutral-tertiary opacity-0 transition-opacity group-hover:opacity-100">
{issue.option === 'CREATE' && 'Create'}
{issue.option === 'EXISTING' && 'Add'}
</span>
}
</ComboboxItem>
))}
{status === 'error' && (
<div className="px-2 py-1 text-red-500">{errorMessage}</div>
)}
</ComboboxGroup>
);
};
export default AiIssueComboboxGroup;
@@ -0,0 +1,185 @@
/**
* 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 { memo } from 'react';
import dayjs from 'dayjs';
import Linkify from 'linkify-react';
import { Badge, Icon, Tag } from '@ufb/react';
import { DATE_TIME_FORMAT, ExpandableText, ImagePreviewButton } from '@/shared';
import { AICell } from '@/entities/ai';
import type { FieldInfo } from '@/entities/field';
import { useAIFIeldFeedbackCellLoading } from '..';
import { getFeedbackPriorityOption } from '../feedback-priority';
interface IProps {
isExpanded: boolean;
field: FieldInfo;
value: unknown;
feedbackId: number;
}
const toRenderableText = (value: unknown) => {
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (value && typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
return undefined;
};
const stripHtmlTags = (value: string) => {
if (!value.includes('<')) return value;
if (typeof window === 'undefined') {
return value
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const document = new DOMParser().parseFromString(value, 'text/html');
return (document.body.textContent)
.replace(/\u00a0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
};
const FeedbackCell: React.FC<IProps> = memo((props) => {
const { isExpanded, field, value, feedbackId } = props;
const renderableText = toRenderableText(value);
const displayText =
field.key === 'contents' && typeof renderableText === 'string' ?
stripHtmlTags(renderableText)
: renderableText;
const priorityColorClassName =
field.key === 'priority' && typeof value === 'string' ?
getFeedbackPriorityOption(value).colorClassName
: undefined;
return (
<ExpandableText isExpanded={isExpanded}>
{typeof value === 'undefined' || value === null ?
undefined
: <>
{field.format === 'date' &&
dayjs(value as string).format(DATE_TIME_FORMAT)}
{field.format === 'multiSelect' && (
<div className="flex gap-2">
{(value as string[])
.sort(
(aKey, bKey) =>
(field.options ?? []).findIndex(
(option) => option.key === aKey,
) -
(field.options ?? []).findIndex(
(option) => option.key === bKey,
),
)
.map((key) => (
<Badge variant="subtle" key={key}>
{
(field.options?.find((option) => option.key === key)
?.name ?? value) as string
}
</Badge>
))}
</div>
)}
{field.format === 'select' && (
<Badge
variant={priorityColorClassName ? 'bold' : 'subtle'}
className={priorityColorClassName}
>
{field.options?.find((option) => option.key === value)?.name ??
''}
</Badge>
)}
{field.format === 'images' && (
<>
{Array.isArray(value) && (value as string[]).length > 0 ?
<ImagePreviewButton urls={value}>
<Tag
aria-label="View images"
variant="outline"
size="small"
className="cursor-pointer gap-1"
onClick={(e) => e.stopPropagation()}
>
<Icon name="RiImageFill" size={12} />
Image
</Tag>
</ImagePreviewButton>
: '-'}
</>
)}
{field.format === 'text' && (
<Linkify
options={{
className: 'text-blue-500 underline',
target: '_blank',
rel: 'noopener noreferrer',
attributes: {
onClick: (e: React.MouseEvent) => e.stopPropagation(),
},
}}
>
{displayText}
</Linkify>
)}
{field.format === 'keyword' && renderableText}
{field.format === 'number' && renderableText}
{field.format === 'aiField' && (
<FeedbackAIFieldCell
value={
value as
| { status: 'loading' | 'success' | 'error'; message: string }
| undefined
}
feedbackId={feedbackId}
field={field}
/>
)}
</>
}
</ExpandableText>
);
});
const FeedbackAIFieldCell: React.FC<{
value?: { status: 'loading' | 'success' | 'error'; message: string };
feedbackId: number;
field: FieldInfo;
}> = memo((props) => {
const { field, value, feedbackId } = props;
const loadingFeedbackIds = useAIFIeldFeedbackCellLoading(
(state) => state.loadingFeedbackIds,
);
return (
<AICell
value={value}
isLoading={
loadingFeedbackIds.has(feedbackId) && field.status === 'ACTIVE'
}
/>
);
});
export default FeedbackCell;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
/**
* 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 {
DragEndEvent,
DragOverEvent,
UniqueIdentifier,
} from '@dnd-kit/core';
import {
closestCenter,
DndContext,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import type { Issue } from '@/entities/issue';
export interface FeedbackKanbanItem {
feedback: Record<string, unknown>;
issues: Issue[];
}
type Items = Record<string, FeedbackKanbanItem[]>;
interface Props<T extends Items> extends React.PropsWithChildren {
items: T;
setItems: React.Dispatch<React.SetStateAction<T>>;
updateStatus: (item: FeedbackKanbanItem, status: string) => Promise<void>;
}
const FeedbackKanbanDndContext = <T extends Items>({
children,
items,
setItems,
updateStatus,
}: Props<T>) => {
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
}),
);
const [snapshot, setSnapshot] = useState<T | null>(null);
const findContainer = (id: UniqueIdentifier, source: Items = items) => {
if (id in source) return id;
return Object.keys(source).find((status) =>
source[status]?.some((item) => String(item.feedback.id) === String(id)),
);
};
const onDragStart = () => {
setSnapshot(items);
};
const onDragOver = ({ active, over }: DragOverEvent) => {
if (!over) return;
const activeContainer = findContainer(active.id);
const overContainer = findContainer(over.id);
if (
!activeContainer ||
!overContainer ||
activeContainer === overContainer
) {
return;
}
const activeItems = items[activeContainer] ?? [];
const overItems = items[overContainer] ?? [];
const activeIndex = activeItems.findIndex(
(item) => String(item.feedback.id) === String(active.id),
);
const item = activeItems[activeIndex];
if (!item) return;
const overIndex = overItems.findIndex(
(candidate) => String(candidate.feedback.id) === String(over.id),
);
const insertIndex = overIndex >= 0 ? overIndex : overItems.length;
setItems(
(current) =>
({
...current,
[activeContainer]: (current[activeContainer] ?? []).filter(
(candidate) => String(candidate.feedback.id) !== String(active.id),
),
[overContainer]: [
...overItems.slice(0, insertIndex),
item,
...overItems.slice(insertIndex),
],
}) as T,
);
};
const onDragEnd = async ({ active, over }: DragEndEvent) => {
if (!over) {
if (snapshot) setItems(snapshot);
setSnapshot(null);
return;
}
const originalContainer =
snapshot ? findContainer(active.id, snapshot) : undefined;
const targetContainer = findContainer(active.id);
const item =
targetContainer ?
items[targetContainer]?.find(
(candidate) => String(candidate.feedback.id) === String(active.id),
)
: undefined;
if (
!originalContainer ||
!targetContainer ||
!item ||
originalContainer === targetContainer
) {
setSnapshot(null);
return;
}
try {
await updateStatus(item, String(targetContainer));
} catch {
if (snapshot) setItems(snapshot);
} finally {
setSnapshot(null);
}
};
const onDragCancel = () => {
if (snapshot) setItems(snapshot);
setSnapshot(null);
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={(event) => void onDragEnd(event)}
onDragCancel={onDragCancel}
>
{children}
</DndContext>
);
};
export default FeedbackKanbanDndContext;
@@ -0,0 +1,333 @@
/**
* 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 { useDroppable } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { motion } from 'framer-motion';
import { Badge, Icon } from '@ufb/react';
import { cn, fetchWithAuthRefresh } from '@/shared';
import type { SearchQuery, TableFilterOperator } from '@/shared';
import type { Issue } from '@/entities/issue';
import IssueKanbanColumnSorting from '@/widgets/issue-table/ui/issue-kanban-column-sorting.ui';
import {
FEEDBACK_STATUS_OPTIONS,
getFeedbackStatusOption,
} from '../feedback-status';
import type { FeedbackStatus, FeedbackStatusOption } from '../feedback-status';
import { useFeedbackSearch } from '../lib';
import type { FeedbackKanbanItem } from './feedback-kanban-dnd-context.ui';
import FeedbackKanbanDndContext from './feedback-kanban-dnd-context.ui';
interface Props {
projectId: number;
channelId: number;
queries: SearchQuery[];
defaultQueries: SearchQuery[];
operator: TableFilterOperator;
fields: { key: string; name: string }[];
feedbackStatuses: Record<string, string>;
onOpenFeedback: (feedback: Record<string, unknown>) => void;
onStatusChanged?: (feedbackId: number, status: string) => void;
}
const getFeedbackTitle = (
feedback: Record<string, unknown>,
fields: { key: string; name: string }[],
) => {
const titleField = fields.find((field) => field.key === 'title');
const title = feedback.title ?? (titleField && feedback[titleField.key]);
return title ? String(title) : `Feedback #${String(feedback.id ?? '-')}`;
};
const baseStyles: React.CSSProperties = { position: 'relative' };
const initialStyles = { x: 0, y: 0, scale: 1 };
const FeedbackKanbanCard = ({
feedback,
issues,
fields,
onOpenFeedback,
}: {
feedback: Record<string, unknown>;
issues: Issue[];
fields: { key: string; name: string }[];
onOpenFeedback: (feedback: Record<string, unknown>) => void;
}) => {
const {
setNodeRef,
attributes,
listeners,
transform,
isDragging,
} = useSortable({ id: String(feedback.id) });
return (
<motion.button
ref={setNodeRef}
layoutId={String(feedback.id)}
style={baseStyles}
animate={
transform ?
{
x: transform.x,
y: transform.y,
scale: isDragging ? 1.05 : 1,
zIndex: isDragging ? 1 : 0,
}
: initialStyles
}
transition={{
duration: !isDragging ? 0.25 : 0,
scale: { duration: 0.25 },
zIndex: { delay: isDragging ? 0 : 0.25 },
}}
{...attributes}
{...(listeners ?? {})}
type="button"
className="bg-neutral-primary border-neutral-tertiary rounded-8 shadow-default flex min-w-0 cursor-grab flex-col gap-2 border p-4 text-left transition hover:opacity-70 active:cursor-grabbing"
onClick={() => onOpenFeedback(feedback)}
>
<p className="text-neutral-primary text-base-strong break-words">
{getFeedbackTitle(feedback, fields)}
</p>
<div className="text-neutral-secondary flex min-w-0 items-start gap-1 text-xs">
<Icon name="RiTicketLine" size={12} className="mt-0.5 shrink-0" />
<span className="break-words">
{issues.length > 0 ?
issues.map((issue) => issue.name).join(', ')
: '-'}
</span>
</div>
</motion.button>
);
};
const FeedbackKanbanColumn = ({
status,
items,
fields,
onOpenFeedback,
}: {
status: FeedbackStatusOption;
items: FeedbackKanbanItem[];
fields: { key: string; name: string }[];
onOpenFeedback: (feedback: Record<string, unknown>) => void;
}) => {
const [sort, setSort] = useState({ key: 'createdAt', value: 'DESC' });
const { setNodeRef, isOver } = useDroppable({ id: status.value });
const sortedItems = useMemo(
() =>
[...items].sort((a, b) => {
const aValue = String(a.feedback[sort.key] ?? '');
const bValue = String(b.feedback[sort.key] ?? '');
return sort.value === 'DESC' ?
bValue.localeCompare(aValue)
: aValue.localeCompare(bValue);
}),
[items, sort],
);
return (
<div
ref={setNodeRef}
className={cn(
'rounded-16 bg-neutral-tertiary flex min-w-0 flex-col gap-2 px-2 py-3',
{ 'ring-primary ring-2': isOver },
)}
>
<div
className={cn(
'text-neutral-inverse rounded-8 text-base-strong flex items-center justify-between px-4 py-1',
status.colorClassName,
)}
>
<div className="flex items-center gap-1">
{status.label}
{items.length ?
<Badge variant="subtle" radius="large">
{items.length.toLocaleString()}
</Badge>
: null}
</div>
<IssueKanbanColumnSorting sort={sort} onSubmit={setSort} />
</div>
{sortedItems.length === 0 ?
<div className="flex h-12 items-center justify-center">
<p className="text-neutral-tertiary text-center text-xs">
There is no feedback on this status.
</p>
</div>
: sortedItems.map(({ feedback, issues }) => (
<FeedbackKanbanCard
key={String(feedback.id)}
feedback={feedback}
issues={issues}
fields={fields}
onOpenFeedback={onOpenFeedback}
/>
))
}
</div>
);
};
const FeedbackKanban = (props: Props) => {
const {
projectId,
channelId,
queries,
defaultQueries,
operator,
fields,
feedbackStatuses,
onOpenFeedback,
onStatusChanged,
} = props;
const { data, isLoading } = useFeedbackSearch(
projectId,
channelId,
{
queries,
defaultQueries,
operator,
sort: { createdAt: 'DESC' },
page: 1,
limit: 100,
},
{ staleTime: 30_000, refetchOnWindowFocus: false },
);
const itemsByStatus = useMemo(() => {
const grouped = Object.fromEntries(
FEEDBACK_STATUS_OPTIONS.map((status) => [
status.value,
[] as FeedbackKanbanItem[],
]),
) as Record<FeedbackStatus, FeedbackKanbanItem[]>;
for (const feedback of data?.items ?? []) {
const issues = (feedback.issues as Issue[] | undefined) ?? [];
const status = getFeedbackStatusOption(
feedbackStatuses[String(feedback.id)],
);
grouped[status.value].push({ feedback, issues });
}
return grouped;
}, [data, feedbackStatuses]);
const [kanbanItems, setKanbanItems] = useState(itemsByStatus);
useEffect(() => {
setKanbanItems(itemsByStatus);
}, [itemsByStatus]);
const [workspaceCode, setWorkspaceCode] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const resolveWorkspaceCode = async () => {
try {
const response = await fetchWithAuthRefresh('/api/support/access');
if (!response.ok) throw new Error('workspace access failed');
const access = (await response.json()) as {
workspaces?: {
workspace_code?: string;
abc_project_id?: number | null;
abc_channel_id?: number | null;
}[];
};
const workspace = (access.workspaces ?? []).find(
(item) =>
item.abc_project_id === projectId &&
item.abc_channel_id === channelId,
);
if (!cancelled) setWorkspaceCode(workspace?.workspace_code ?? null);
} catch {
if (!cancelled) setWorkspaceCode(null);
}
};
void resolveWorkspaceCode();
return () => {
cancelled = true;
};
}, [channelId, projectId]);
const updateFeedbackStatus = async (
item: FeedbackKanbanItem,
status: string,
) => {
if (!workspaceCode) {
throw new Error('피드백 상태를 저장할 workspace를 찾을 수 없습니다.');
}
const feedbackId = Number(item.feedback.id);
const query = new URLSearchParams({
workspaceCode,
abcFeedbackId: String(feedbackId),
});
const response = await fetchWithAuthRefresh(
'/api/support/tickets/' +
feedbackId +
'/feedback-status?' +
query.toString(),
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedback_status: status }),
},
);
const result = (await response.json()) as { message?: string };
if (!response.ok) {
throw new Error(result.message ?? '피드백 상태 저장에 실패했습니다.');
}
onStatusChanged?.(feedbackId, status);
};
if (isLoading) {
return (
<div className="text-neutral-secondary p-6 text-sm">
Loading feedback...
</div>
);
}
return (
<FeedbackKanbanDndContext
items={kanbanItems}
setItems={setKanbanItems}
updateStatus={updateFeedbackStatus}
>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-6">
{FEEDBACK_STATUS_OPTIONS.map((status) => (
<FeedbackKanbanColumn
key={status.value}
status={status}
items={kanbanItems[status.value]}
fields={fields}
onOpenFeedback={onOpenFeedback}
/>
))}
</div>
</FeedbackKanbanDndContext>
);
};
export default FeedbackKanban;
@@ -0,0 +1,148 @@
/**
* 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 { useTranslation } from 'next-i18next';
import { Button, Icon, Tabs, TabsList, TabsTrigger } from '@ufb/react';
interface Props {
channels: { id: number; name: string }[];
currentChannelId: number;
setCurrentChannelId: (id: number) => void;
totalItems?: number;
}
const FeedbackTableChannelSelection = (props: Props) => {
const { channels, currentChannelId, setCurrentChannelId, totalItems } = props;
const { t } = useTranslation();
const tabsContainerRef = useRef<HTMLDivElement>(null);
const [showScrollButtons, setShowScrollButtons] = useState(false);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const checkScrollState = () => {
const container = tabsContainerRef.current;
if (container) {
const isScrollable = container.scrollWidth > container.clientWidth;
setShowScrollButtons(isScrollable);
if (isScrollable) {
setShowLeftGradient(container.scrollLeft > 0);
setShowRightGradient(
0.5 + container.scrollLeft + container.clientWidth <
container.scrollWidth,
);
} else {
setShowLeftGradient(false);
setShowRightGradient(false);
}
}
};
useEffect(() => {
const container = tabsContainerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver(() => {
checkScrollState();
});
resizeObserver.observe(container);
checkScrollState();
container.addEventListener('scroll', checkScrollState);
return () => {
resizeObserver.disconnect();
container.removeEventListener('scroll', checkScrollState);
};
}, [channels, tabsContainerRef]);
const handleScroll = (direction: 'left' | 'right') => {
const container = tabsContainerRef.current;
if (container) {
const scrollAmount = 200;
if (direction === 'left') {
container.scrollLeft -= scrollAmount;
} else {
container.scrollLeft += scrollAmount;
}
}
};
return (
<div className="relative flex items-center overflow-auto">
<div className="scrollbar-hide relative flex-1 overflow-auto">
{showLeftGradient && (
<div className="pointer-events-none absolute bottom-0 left-0 top-0 z-10 w-12 bg-gradient-to-r from-[var(--bg-neutral-primary)] to-transparent" />
)}
<Tabs
value={String(currentChannelId)}
onValueChange={(v) => setCurrentChannelId(Number(v))}
className="scrollbar-hide overflow-auto"
ref={tabsContainerRef}
>
<TabsList className="scrollbar-hide">
{channels.length === 0 ?
<TabsTrigger value="-1">
{t('v2.text.no-data.channel')}
</TabsTrigger>
: channels.map((channel) => (
<TabsTrigger key={channel.id} value={String(channel.id)}>
{channel.name}
{currentChannelId === channel.id && (
<span className="ml-1 font-bold">{totalItems}</span>
)}
</TabsTrigger>
))
}
</TabsList>
</Tabs>
{showRightGradient && (
<div className="pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-12 bg-gradient-to-l from-[var(--bg-neutral-primary)] to-transparent" />
)}
</div>
{showScrollButtons && (
<div className="ml-2 flex items-center gap-2">
<Button
variant="outline"
radius="large"
onClick={() => handleScroll('left')}
aria-label="Scroll left"
disabled={!showLeftGradient}
>
<Icon name="RiArrowLeftLine" />
</Button>
<Button
variant="outline"
radius="large"
onClick={() => handleScroll('right')}
aria-label="Scroll right"
disabled={!showRightGradient}
>
<Icon name="RiArrowRightLine" />
</Button>
</div>
)}
</div>
);
};
export default FeedbackTableChannelSelection;
@@ -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 { useMemo } from 'react';
import { useRouter } from 'next/router';
import type { Table } from '@tanstack/react-table';
import { useTranslation } from 'next-i18next';
import {
Dropdown,
DropdownContent,
DropdownItem,
DropdownLabel,
DropdownTrigger,
Icon,
toast,
} from '@ufb/react';
import type { SearchQuery, TableFilterOperator } from '@/shared';
import type { Field } from '@/entities/field';
import type { Feedback } from '../feedback.type';
import useFeedbackDownload from '../lib/use-feedback-download';
interface Props {
fields: Field[];
defaultQueries: SearchQuery[];
queries: SearchQuery[];
disabled: boolean;
table: Table<Feedback>;
operator: TableFilterOperator;
totalItems: number;
}
const FeedbackTableDownload = (props: Props) => {
const {
fields,
queries,
disabled,
table,
operator,
totalItems,
defaultQueries,
} = props;
const router = useRouter();
const { channelId, projectId } = router.query;
const { t } = useTranslation();
const { mutateAsync: download } = useFeedbackDownload({
params: { channelId: Number(channelId), projectId: Number(projectId) },
});
const visibleColumns = table.getVisibleFlatColumns();
const { rowSelection } = table.getState();
const feedbackIds = useMemo(
() =>
Object.entries(rowSelection)
.filter(([_, v]) => !!v)
.map(([k]) => Number(k)),
[rowSelection],
);
const fieldIds = useMemo(
() =>
fields
.filter((v) => visibleColumns.some((column) => column.id === v.key))
.sort((a, b) => a.order - b.order)
.map((v) => v.id),
[fields, visibleColumns],
);
const exportFeedbackResponse = (type: 'xlsx' | 'csv') => () => {
void toast.promise(
download({
type,
fieldIds,
defaultQueries,
queries,
operator,
filterFeedbackIds: feedbackIds.length > 0 ? feedbackIds : undefined,
}),
{
success: () => t('main.feedback.download.success'),
error: () => t('main.feedback.download.error'),
loading: t('main.feedback.download.loading'),
},
);
};
return (
<Dropdown>
<DropdownTrigger disabled={disabled}>
<Icon name="RiDownload2Line" />
Export
</DropdownTrigger>
<DropdownContent>
<DropdownLabel>
{feedbackIds.length > 0 ? feedbackIds.length : totalItems} Items
</DropdownLabel>
<DropdownItem onClick={exportFeedbackResponse('xlsx')}>
Excel
</DropdownItem>
<DropdownItem onClick={exportFeedbackResponse('csv')}>CSV</DropdownItem>
</DropdownContent>
</Dropdown>
);
};
export default FeedbackTableDownload;
@@ -0,0 +1,43 @@
/**
* 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 { Table } from '@tanstack/react-table';
import { Button, Icon } from '@ufb/react';
import { cn } from '@/shared';
import type { Feedback } from '../feedback.type';
interface Props {
table: Table<Feedback>;
}
const FeedbackTableExpand = ({ table }: Props) => {
return (
<Button
variant="outline"
className={cn({
'!bg-neutral-tertiary': table.getIsAllRowsExpanded(),
})}
onClick={() => table.toggleAllRowsExpanded()}
>
<Icon name="RiLineHeight" />
Expand
</Button>
);
};
export default FeedbackTableExpand;
@@ -0,0 +1,75 @@
/**
* 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 { Table } from '@tanstack/react-table';
import {
Badge,
Combobox,
ComboboxContent,
ComboboxList,
ComboboxSelectItem,
ComboboxTrigger,
Icon,
} from '@ufb/react';
import type { FieldInfo } from '@/entities/field';
import { FieldFormatLabel } from '@/entities/field';
import type { Feedback } from '../feedback.type';
interface Props {
table: Table<Feedback>;
fields: FieldInfo[];
}
const FeedbackTableViewOptions = ({ table, fields }: Props) => {
return (
<Combobox>
<ComboboxTrigger trigger="click">
<Icon name="RiEyeLine" />
View
<Badge variant="subtle">{table.getVisibleFlatColumns().length}</Badge>
</ComboboxTrigger>
<ComboboxContent>
<ComboboxList maxHeight="400px">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide(),
)
.map((column) => {
const field = fields.find((f) => f.key === column.id);
if (!field) return null;
return (
<ComboboxSelectItem
key={column.id}
onSelect={() =>
column.toggleVisibility(!column.getIsVisible())
}
checked={column.getIsVisible()}
>
<FieldFormatLabel format={field.format} name={field.name} />
</ComboboxSelectItem>
);
})}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};
export default FeedbackTableViewOptions;
@@ -0,0 +1,748 @@
/**
* 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 {
OnChangeFn,
PaginationState,
Updater,
VisibilityState,
} from '@tanstack/react-table';
import {
getCoreRowModel,
getExpandedRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useOverlay } from '@toss/use-overlay';
import { useTranslation } from 'next-i18next';
import { parseAsString, useQueryState } from 'nuqs';
import {
Badge,
Button,
Icon,
toast,
ToggleGroup,
ToggleGroupItem,
} from '@ufb/react';
import type { TableFilterField } from '@/shared';
import {
BasicTable,
DateRangePicker,
DeleteDialog,
fetchWithAuthRefresh,
TableFilterPopover,
TablePagination,
useOAIMutation,
usePermissions,
useSort,
} from '@/shared';
import type { Channel } from '@/entities/channel';
import type { Field } from '@/entities/field';
import { AISparklingIcon } from '@/assets';
import { getColumns } from '../feedback-table-columns';
import {
useAIFIeldFeedbackCellLoading,
useFeedbackQueryConverter,
useFeedbackSearch,
} from '../lib';
import FeedbackDetailSheet from './feedback-detail-sheet.ui';
import FeedbackKanban from './feedback-kanban.ui';
import FeedbackTableChannelSelection from './feedback-table-channel-selection.ui';
import FeedbackTableDownload from './feedback-table-download.ui';
import FeedbackTableExpand from './feedback-table-expand.ui';
import FeedbackTableViewOptions from './feedback-table-view-options.ui';
interface FeedbackMetrics {
today_first_count: number;
answer_waiting_count: number;
issue_linked_count: number;
issue_link_rate: number;
average_processing_minutes: number | null;
}
interface Props {
projectId: number;
channels: Channel[];
currentChannel: Channel;
setCurrentChannelId: (id: number) => void;
filterFields: TableFilterField[];
fields: Field[];
}
const FeedbackTable = (props: Props) => {
const {
channels,
currentChannel,
setCurrentChannelId,
projectId,
filterFields,
fields,
} = props;
const { t } = useTranslation();
const perms = usePermissions();
const [viewType, setViewType] = useQueryState(
'viewType',
parseAsString.withDefault('list'),
);
const [openFeedbackId, setOpenFeedbackId] = useState<number | null>(null);
const [openFeedback, setOpenFeedback] = useState<Record<
string,
unknown
> | null>(null);
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [rowCount, setRowCount] = useState(0);
const [pageCount, setPageCount] = useState(0);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [columnVisibility, setColumnVisibility] = useState<
Record<number, VisibilityState>
>({});
const [metrics, setMetrics] = useState<FeedbackMetrics | null>(null);
const [isMetricsLoading, setIsMetricsLoading] = useState(false);
const [feedbackStatuses, setFeedbackStatuses] = useState<
Record<string, string>
>({});
const [assigneeByFeedbackId, setAssigneeByFeedbackId] = useState<
Record<string, string>
>({});
const [assignedFeedbackIds, setAssignedFeedbackIds] = useState<
number[] | null
>(null);
const [showAssignedOnly, setShowAssignedOnly] = useState(false);
useEffect(() => {
const controller = new AbortController();
setIsMetricsLoading(true);
const loadMetrics = async () => {
try {
const response = await fetchWithAuthRefresh(
`/api/support/metrics?projectId=${projectId}&channelId=${currentChannel.id}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error('metrics request failed');
}
setMetrics((await response.json()) as FeedbackMetrics);
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
setMetrics(null);
}
} finally {
if (!controller.signal.aborted) {
setIsMetricsLoading(false);
}
}
};
void loadMetrics();
return () => controller.abort();
}, [currentChannel.id, projectId]);
useEffect(() => {
const controller = new AbortController();
const loadFeedbackStatuses = async () => {
try {
const response = await fetchWithAuthRefresh(
`/api/support/statuses?projectId=${projectId}&channelId=${currentChannel.id}`,
{ signal: controller.signal },
);
if (!response.ok) throw new Error('feedback statuses request failed');
const records = (await response.json()) as {
feedback_id: number;
feedback_status: string;
}[];
setFeedbackStatuses(
Object.fromEntries(
records.map((record) => [
String(record.feedback_id),
record.feedback_status,
]),
),
);
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
setFeedbackStatuses({});
}
}
};
void loadFeedbackStatuses();
return () => controller.abort();
}, [currentChannel.id, projectId]);
useEffect(() => {
const controller = new AbortController();
setAssignedFeedbackIds(null);
const loadAssignees = async () => {
try {
const accessResponse = await fetchWithAuthRefresh(
'/api/support/access',
{ signal: controller.signal },
);
if (!accessResponse.ok)
throw new Error('support access request failed');
const access = (await accessResponse.json()) as {
user_id?: string;
tenant_id?: string;
email?: string | null;
workspaces?: {
workspace_code?: string;
abc_project_id?: number | null;
abc_channel_id?: number | null;
}[];
};
const workspaceCode = (access.workspaces ?? []).find(
(workspace) =>
workspace.abc_project_id === projectId &&
workspace.abc_channel_id === currentChannel.id,
)?.workspace_code;
if (!workspaceCode) {
setAssigneeByFeedbackId({});
setAssignedFeedbackIds([]);
return;
}
const ticketsResponse = await fetchWithAuthRefresh(
`/api/support/workspaces/${encodeURIComponent(workspaceCode)}/tickets`,
{ signal: controller.signal },
);
if (!ticketsResponse.ok)
throw new Error('support tickets request failed');
const tickets = (await ticketsResponse.json()) as unknown;
const assignees: Record<string, string> = {};
const assignedIds = new Set<number>();
if (Array.isArray(tickets)) {
for (const ticket of tickets) {
if (typeof ticket !== 'object' || ticket === null) continue;
const record = ticket as Record<string, unknown>;
const assigneeName =
typeof record.assignee_name === 'string' ? record.assignee_name
: typeof record.assignee_email === 'string' ?
record.assignee_email
: typeof record.assignee_id === 'string' ? record.assignee_id
: '';
if (!assigneeName) continue;
const matchesCurrentUser =
(typeof record.assignee_id === 'string' &&
record.assignee_id === access.user_id &&
(!record.assignee_tenant_id ||
record.assignee_tenant_id === access.tenant_id)) ||
(typeof record.assignee_email === 'string' &&
record.assignee_email === access.email);
const ticketId = record.ticket_id;
if (typeof ticketId === 'string' || typeof ticketId === 'number') {
assignees[String(ticketId)] = assigneeName;
}
const extraFields = record.extra_fields;
let mappedFeedbackId: string | number | undefined;
if (typeof extraFields === 'object' && extraFields !== null) {
const abcFeedbackId = (extraFields as Record<string, unknown>)
.abc_feedback_id;
if (
typeof abcFeedbackId === 'string' ||
typeof abcFeedbackId === 'number'
) {
mappedFeedbackId = abcFeedbackId;
assignees[String(abcFeedbackId)] = assigneeName;
}
}
if (matchesCurrentUser) {
const candidateId = mappedFeedbackId ?? ticketId;
const numericId = Number(candidateId);
if (Number.isInteger(numericId) && numericId > 0) {
assignedIds.add(numericId);
}
}
}
}
setAssigneeByFeedbackId(assignees);
setAssignedFeedbackIds([...assignedIds]);
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
setAssigneeByFeedbackId({});
setAssignedFeedbackIds([]);
}
}
};
void loadAssignees();
return () => controller.abort();
}, [currentChannel.id, projectId]);
const formatProcessingTime = (minutes: number | null) => {
if (minutes === null) return '-';
return `${(minutes / 60).toFixed(1)}h`;
};
const onChangeColumnVisibility: OnChangeFn<VisibilityState> = (
updaterOrValue: Updater<VisibilityState>,
) => {
if (typeof updaterOrValue !== 'function') {
setColumnVisibility({
...columnVisibility,
[currentChannel.id]: updaterOrValue,
});
} else {
setColumnVisibility((prev) => ({
...prev,
[currentChannel.id]: updaterOrValue(
columnVisibility[currentChannel.id] ?? {},
),
}));
}
};
const {
queries,
tableFilters,
dateRange,
operator,
updateTableFilters,
updateDateRange,
defaultQueries,
} = useFeedbackQueryConverter({
projectId,
filterFields,
feedbackSearchMaxDays: currentChannel.feedbackSearchMaxDays,
});
const onChangeCurrentChannelId = (channelId: number) => {
setCurrentChannelId(channelId);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
table.resetRowSelection();
};
const onChangeTableFilters: typeof updateTableFilters = async (...input) => {
await updateTableFilters(...input);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
table.resetRowSelection();
};
const onChangeDateRange: typeof updateDateRange = async (...input) => {
await updateDateRange(...input);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
table.resetRowSelection();
};
const columns = useMemo(
() => getColumns(fields, false, feedbackStatuses, assigneeByFeedbackId),
[assigneeByFeedbackId, feedbackStatuses, fields],
);
const searchQueries = useMemo(
() =>
showAssignedOnly ?
[
...queries,
{
key: 'ids',
value:
assignedFeedbackIds && assignedFeedbackIds.length > 0 ?
assignedFeedbackIds
: [0],
condition: 'CONTAINS' as const,
},
]
: queries,
[assignedFeedbackIds, queries, showAssignedOnly],
);
const searchOperator = showAssignedOnly ? 'AND' : operator;
const table = useReactTable({
columns,
data: rows,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
pageCount,
rowCount,
state: {
pagination,
columnVisibility: columnVisibility[currentChannel.id],
},
initialState: { sorting: [{ id: 'createdAt', desc: true }] },
manualSorting: true,
onPaginationChange: setPagination,
onColumnVisibilityChange: onChangeColumnVisibility,
getRowId: (row) => String(row.id),
});
const { rowSelection, sorting } = table.getState();
const sort = useSort(sorting);
const selectedRowIds = useMemo(() => {
return Object.entries(rowSelection).reduce(
(acc, [key, value]) => (value ? acc.concat(Number(key)) : acc),
[] as number[],
);
}, [rowSelection]);
const {
data: feedbackData,
isLoading,
refetch,
} = useFeedbackSearch(
projectId,
currentChannel.id,
{
queries: searchQueries,
operator: searchOperator,
sort,
page: pagination.pageIndex + 1,
limit: pagination.pageSize,
defaultQueries,
},
{
staleTime: 1000 * 60,
throwOnError(error) {
if (error.code === 'LargeWindow') {
toast.error('Please narrow down the search range.');
}
return false;
},
},
);
useEffect(() => {
const items = feedbackData?.items ?? [];
const totalItems = feedbackData?.meta.totalItems ?? 0;
const totalPages = feedbackData?.meta.totalPages ?? 0;
setRows(items);
setPageCount(totalPages);
setRowCount(totalItems);
setPagination((current) => ({
...current,
pageIndex:
totalPages === 0 ? 0 : Math.min(current.pageIndex, totalPages - 1),
}));
table.resetRowSelection();
}, [feedbackData]);
const { mutateAsync: deleteFeedback } = useOAIMutation({
method: 'delete',
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks',
pathParams: { channelId: currentChannel.id, projectId },
queryOptions: {
onSuccess: async () => {
await refetch();
toast.success(t('v2.toast.success'));
table.resetRowSelection();
},
},
});
const currentFeedback = useMemo(
() => openFeedback ?? rows.find((v) => v.id === openFeedbackId),
[openFeedback, rows, openFeedbackId],
);
const openFeedbackDetail = (feedback?: Record<string, unknown> | null) => {
if (feedback?.id === undefined || feedback.id === null) return;
setOpenFeedbackId(Number(feedback.id));
setOpenFeedback(feedback);
};
const closeFeedbackDetail = () => {
setOpenFeedbackId(null);
setOpenFeedback(null);
};
return (
<div className="flex h-full flex-col gap-4">
<div className="grid grid-cols-4 gap-3">
{[
{
label: '오늘 먼저 볼 건',
description: '우선순위 높음, 저장/종료 관련 문의',
value: metrics?.today_first_count,
suffix: '건',
},
{
label: '답변 대기',
description: '고객 답변 또는 내부 확인이 필요한 항목',
value: metrics?.answer_waiting_count,
suffix: '건',
},
{
label: '이슈 연결률',
description: '개발팀 추적이 필요한 피드백 연결 상태',
value: metrics?.issue_link_rate,
suffix: '%',
},
{
label: '평균 처리 시간',
description: '최근 업데이트 기준 운영 속도',
value: metrics?.average_processing_minutes,
suffix: '',
},
].map((metric) => (
<div
key={metric.label}
className="rounded-lg border border-neutral-200 bg-white px-4 py-3"
>
<p className="text-neutral-secondary text-xs">{metric.label}</p>
<p className="text-neutral-primary mt-1 text-xl font-semibold">
{isMetricsLoading ?
'...'
: metric.label === '평균 처리 시간' ?
formatProcessingTime(metric.value ?? null)
: `${metric.value ?? 0}${metric.suffix}`}
</p>
<p className="text-neutral-secondary mt-1 text-xs">
{metric.description}
</p>
</div>
))}
</div>
<div className="flex flex-shrink justify-between gap-2">
<FeedbackTableChannelSelection
channels={channels}
currentChannelId={currentChannel.id}
setCurrentChannelId={onChangeCurrentChannelId}
totalItems={feedbackData?.meta.totalItems ?? 0}
/>
<div className="flex flex-shrink-0 gap-2 [&>button]:min-w-20 [&>button]:flex-shrink-0">
{selectedRowIds.length > 0 && (
<>
<FeedbackDeleteButton
selectedRowIds={selectedRowIds}
deleteFeedback={deleteFeedback}
/>
{fields.filter(
(v) => v.format === 'aiField' && v.status === 'ACTIVE',
).length > 0 && (
<RunAIButton
selectedRowIds={selectedRowIds}
projectId={projectId}
afterProcess={() => {
table.resetRowSelection();
void refetch();
}}
/>
)}
</>
)}
<DateRangePicker
onChange={onChangeDateRange}
value={dateRange}
maxDate={new Date()}
maxDays={currentChannel.feedbackSearchMaxDays}
allowEntirePeriod={currentChannel.feedbackSearchMaxDays === -1}
tooltipContent={t('tooltip.feedback-date-picker-button')}
/>
<Button
variant={showAssignedOnly ? 'primary' : 'outline'}
disabled={assignedFeedbackIds === null}
onClick={() => {
setShowAssignedOnly((value) => !value);
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
table.resetRowSelection();
}}
>
<Icon name="RiUserLine" />
</Button>
<TableFilterPopover
filterFields={filterFields.filter(
(v) =>
v.key !== 'assignee' &&
(v.key === 'issueIds' ||
table.getAllColumns().some((column) => column.id === v.key)),
)}
operator={operator}
onSubmit={onChangeTableFilters}
tableFilters={tableFilters}
table={table}
/>
<FeedbackTableViewOptions table={table} fields={fields} />
<ToggleGroup
type="single"
value={viewType}
onValueChange={(value) => {
if (value) void setViewType(value);
}}
>
<ToggleGroupItem value="kanban" className="w-24">
<Icon name="RiCarouselView" />
Kanban
</ToggleGroupItem>
<ToggleGroupItem value="list" className="w-24">
<Icon name="RiListCheck" />
List
</ToggleGroupItem>
</ToggleGroup>
<FeedbackTableExpand table={table} />
<FeedbackTableDownload
table={table}
fields={fields}
queries={searchQueries}
defaultQueries={defaultQueries}
disabled={!perms.includes('feedback_download_read')}
operator={searchOperator}
totalItems={feedbackData?.meta.totalItems ?? 0}
/>
</div>
</div>
{viewType === 'kanban' ?
<FeedbackKanban
projectId={projectId}
channelId={currentChannel.id}
queries={searchQueries}
defaultQueries={defaultQueries}
operator={searchOperator}
fields={fields}
feedbackStatuses={feedbackStatuses}
onStatusChanged={(feedbackId, status) =>
setFeedbackStatuses((previous) => ({
...previous,
[String(feedbackId)]: status,
}))
}
onOpenFeedback={openFeedbackDetail}
/>
: <>
<BasicTable
table={table}
onClickRow={(_, row) => openFeedbackDetail(row)}
isLoading={isLoading}
isFiltered={searchQueries.length > 0}
resiable
/>
<TablePagination table={table} />
</>
}
{currentFeedback && (
<FeedbackDetailSheet
onClickDelete={() =>
deleteFeedback({ feedbackIds: [currentFeedback.id as number] })
}
isOpen={!!openFeedbackId}
close={closeFeedbackDetail}
feedback={currentFeedback}
projectId={projectId}
channelId={currentChannel.id}
refetchFeedback={refetch}
onStatusChanged={(feedbackId, status) =>
setFeedbackStatuses((previous) => ({
...previous,
[String(feedbackId)]: status,
}))
}
/>
)}
</div>
);
};
const FeedbackDeleteButton = (props: {
selectedRowIds: number[];
deleteFeedback: (params: { feedbackIds: number[] }) => Promise<void>;
}) => {
const { selectedRowIds, deleteFeedback } = props;
const { t } = useTranslation();
const perms = usePermissions();
const overlay = useOverlay();
const openDeleteFeedbacksDialog = () => {
overlay.open(({ close, isOpen }) => (
<DeleteDialog
close={close}
isOpen={isOpen}
onClickDelete={() => deleteFeedback({ feedbackIds: selectedRowIds })}
/>
));
};
return (
<Button
variant="outline"
className="!text-tint-red"
onClick={openDeleteFeedbacksDialog}
disabled={!perms.includes('feedback_delete')}
>
<Icon name="RiDeleteBin6Line" />
{t('v2.button.name.delete', { name: 'Feedback' })}
<Badge variant="subtle" className="!text-tint-red">
{selectedRowIds.length}
</Badge>
</Button>
);
};
const RunAIButton = (props: {
selectedRowIds: number[];
projectId: number;
afterProcess: () => void;
}) => {
const { selectedRowIds, projectId, afterProcess } = props;
const { t } = useTranslation();
const perms = usePermissions();
const setLoadingFeedbackIds = useAIFIeldFeedbackCellLoading(
(state) => state.setLoadingFeedbackIds,
);
const { mutateAsync: processAI, isPending: isPendingAIProcess } =
useOAIMutation({
method: 'post',
path: '/api/admin/projects/{projectId}/ai/process',
pathParams: { projectId },
queryOptions: {
onSuccess() {
toast.success(t('v2.toast.success'));
},
onSettled() {
afterProcess();
setLoadingFeedbackIds([]);
},
onError(error) {
toast.error(error.message);
},
},
});
return (
<Button
variant="outline"
onClick={() => {
setLoadingFeedbackIds(selectedRowIds);
toast.promise(processAI({ feedbackIds: selectedRowIds }), {
loading: 'Loading',
success: () => 'Success',
});
}}
disabled={isPendingAIProcess || !perms.includes('feedback_update')}
>
<AISparklingIcon />
Run AI
<Badge variant="subtle">{selectedRowIds.length}</Badge>
</Button>
);
};
export default FeedbackTable;
@@ -0,0 +1,21 @@
/**
* 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.
*/
export { default as FeedbackDetailSheet } from './feedback-detail-sheet.ui';
export { default as FeedbackTableDownload } from './feedback-table-download.ui';
export { default as FeedbackTableExpand } from './feedback-table-expand.ui';
export { default as FeedbackTableViewOptions } from './feedback-table-view-options.ui';
export { default as FeedbackTableChannelSelection } from './feedback-table-channel-selection.ui';
export { default as FeedbackTable } from './feedback-table.ui';
@@ -0,0 +1,130 @@
/**
* 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 { useRouter } from 'next/router';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import {
Badge,
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
Icon,
TextInput,
toast,
} from '@ufb/react';
import { useOAIMutation, usePermissions } from '@/shared';
import type { Issue } from '@/entities/issue';
interface Props {
issue: Issue;
}
const IssueCellEditCombobox = (props: Props) => {
const { issue } = props;
const { t } = useTranslation();
const perms = usePermissions();
const router = useRouter();
const projectId = Number(router.query.projectId);
const [inputIssueName, setInputIssueName] = useState(issue.name);
const queryClient = useQueryClient();
const refetch = async () => {
await queryClient.invalidateQueries({
queryKey: [
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
],
});
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
});
};
const { mutate: update } = useOAIMutation({
method: 'put',
path: '/api/admin/projects/{projectId}/issues/{issueId}',
pathParams: { issueId: issue.id, projectId },
queryOptions: {
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
},
});
const { mutate: deleteIssue } = useOAIMutation({
method: 'delete',
path: '/api/admin/projects/{projectId}/issues/{issueId}',
pathParams: { projectId, issueId: issue.id },
queryOptions: {
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
},
});
return (
<Dropdown>
<DropdownTrigger asChild>
<button>
<Badge onClick={(e) => e.stopPropagation()} variant="subtle">
Edit
</Badge>
</button>
</DropdownTrigger>
<DropdownContent onClick={(e) => e.stopPropagation()} side="right">
<div className="border-b">
<TextInput
className="border-none"
value={inputIssueName}
onChange={(e) => setInputIssueName(e.currentTarget.value)}
disabled={!perms.includes('issue_update')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
const name = inputIssueName.trim();
update({ ...issue, name });
setInputIssueName(name);
}
}}
/>
</div>
<DropdownItem
className="gap-2"
onSelect={() => deleteIssue(undefined)}
disabled={!perms.includes('issue_delete')}
>
<Icon name="RiDeleteBin5Fill" size={16} />
Delete Issue
</DropdownItem>
</DropdownContent>
</Dropdown>
);
};
export default IssueCellEditCombobox;
@@ -0,0 +1,339 @@
/**
* 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 dynamic from 'next/dynamic';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { useThrottle } from 'react-use';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
Icon,
Tag,
toast,
} from '@ufb/react';
import {
client,
cn,
commandFilter,
useOAIMutation,
usePermissions,
} from '@/shared';
import { IssueBadge, useIssueSearchInfinite } from '@/entities/issue';
import type { Issue } from '@/entities/issue';
import { useFeedbackSearch } from '../lib';
import AiIssueComboboxGroup from './ai-issue-combobox-group.ui';
const InfiniteScrollArea = dynamic(
() => import('@/shared/ui/infinite-scroll-area.ui'),
{ ssr: false },
);
interface IProps {
issues?: Issue[];
feedbackId?: number;
isPreview?: boolean;
showSameIssueFeedbackLink?: boolean;
}
const IssueCell: React.FC<IProps> = (props) => {
const {
feedbackId,
issues,
isPreview = false,
showSameIssueFeedbackLink = false,
} = props;
const { t } = useTranslation();
const router = useRouter();
const projectId = Number(router.query.projectId);
const channelId = Number(router.query.channelId);
const perms = usePermissions(projectId);
const [inputValue, setInputValue] = useState('');
const throttledvalue = useThrottle(inputValue, 500);
const queryClient = useQueryClient();
const { data } = useFeedbackSearch(
projectId,
channelId,
{
queries: [{ key: 'id', value: feedbackId, condition: 'IS' }],
operator: 'AND',
},
{ enabled: !issues },
);
const currentIssues = (issues ?? data?.items[0]?.issues ?? []) as Issue[];
const refetch = async () => {
await queryClient.invalidateQueries({
queryKey: [
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
],
});
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
});
};
const {
data: allIssueData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useIssueSearchInfinite(Number(projectId), {
limit: 50,
queries: [{ key: 'name', value: throttledvalue, condition: 'CONTAINS' }],
sort: { name: 'ASC' },
});
const allIssues = useMemo(() => {
return allIssueData.pages
.map((v) => v?.items)
.filter((v) => !!v)
.flat()
.sort(
(a, b) =>
(currentIssues.some((issue) => issue.id === a.id) ? -1 : 1) -
(currentIssues.some((issue) => issue.id === b.id) ? -1 : 1),
);
}, [allIssueData, currentIssues]);
const { mutate: attatchIssue } = useMutation({
mutationFn: async ({ issueId }: { issueId: number }) => {
if (!feedbackId) return;
const { data } = await client.post({
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/{feedbackId}/issue/{issueId}',
pathParams: { projectId, channelId, feedbackId, issueId },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
const { mutate: detachIssue } = useMutation({
mutationFn: async ({ issueId }: { issueId: number }) => {
if (!feedbackId) return;
const { data } = await client.delete({
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/{feedbackId}/issue/{issueId}',
pathParams: { projectId, channelId, feedbackId, issueId },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
const { mutateAsync: createIssue } = useOAIMutation({
method: 'post',
path: '/api/admin/projects/{projectId}/issues',
pathParams: { projectId },
queryOptions: {
async onSuccess() {
await refetch();
setInputValue('');
toast.success(t('v2.toast.success'));
},
},
});
const onClickIssue = (issueId: number) => {
if (currentIssues.some((v) => v.id === issueId)) {
detachIssue({ issueId });
} else {
attatchIssue({ issueId });
}
};
const onCreateIssue = async (name: string) => {
const data = await createIssue({
name,
description: '',
});
if (!data) return;
attatchIssue({ issueId: data.id });
};
return (
<div
className="flex flex-wrap items-center gap-1 rounded"
onClick={(e) => e.stopPropagation()}
>
<Combobox>
<ComboboxTrigger asChild>
<button
disabled={!perms.includes('feedback_issue_update')}
className={cn({
'opacity-50': !perms.includes('feedback_issue_update'),
})}
>
{!isPreview && (
<Tag
variant="outline"
className={cn('w-8 cursor-pointer justify-center', {
'hover:bg-inherit': !perms.includes('feedback_issue_update'),
})}
>
+
</Tag>
)}
</button>
</ComboboxTrigger>
<ComboboxContent
options={{ filter: commandFilter }}
className="w-[320px]"
>
<ComboboxInput
onClick={(e) => e.stopPropagation()}
onValueChange={(value) => setInputValue(value)}
value={inputValue}
/>
{!!feedbackId && !inputValue && (
<AiIssueComboboxGroup
projectId={projectId}
channelId={channelId}
feedbackId={feedbackId}
onSelect={async ({ name, option, id }) => {
if (option === 'CREATE') {
await onCreateIssue(name);
}
if (option === 'EXISTING' && id) {
onClickIssue(id);
}
}}
currentIssues={currentIssues}
/>
)}
<ComboboxList maxHeight="333px">
<ComboboxGroup
heading={
<span className="text-neutral-tertiary text-base-normal">
Issue List
</span>
}
>
<ComboboxEmpty>{t('v2.text.no-data.default')}</ComboboxEmpty>
{allIssues.map((issue) => (
<ComboboxItem
key={issue.id}
onSelect={() => onClickIssue(issue.id)}
value={issue.name}
className="group justify-between"
disabled={!perms.includes('feedback_issue_update')}
>
<IssueBadge
key={issue.id}
name={issue.name}
status={issue.status}
/>
{currentIssues.some((v) => v.id === issue.id) ?
<Icon name="RiCheckLine" size={16} />
: <span className="text-small-normal text-neutral-tertiary opacity-0 transition-opacity group-hover:opacity-100">
Add
</span>
}
</ComboboxItem>
))}
<InfiniteScrollArea
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
</ComboboxGroup>
</ComboboxList>
{!!inputValue &&
perms.includes('issue_create') &&
!currentIssues.some((issue) => issue.name === inputValue) &&
!allIssues.some((issue) => issue.name === inputValue) && (
<ComboboxItem
value={inputValue}
className="justify-between"
onSelect={() => onCreateIssue(inputValue)}
>
<IssueBadge name={inputValue} status="INIT" />
<span className="text-neutral-tertiary text-small-normal">
Create
</span>
</ComboboxItem>
)}
</ComboboxContent>
</Combobox>
{currentIssues.map((issue) => {
const sameIssueFeedbackHref =
showSameIssueFeedbackLink ?
{
pathname: '/main/project/[projectId]/feedback',
query: {
projectId,
channelId,
queries: JSON.stringify([
{
key: 'issueIds',
value: [issue.id],
condition: 'CONTAINS',
},
]),
defaultQueries: JSON.stringify([]),
},
}
: null;
return (
<div key={issue.id} className="inline-flex items-center gap-1">
{showSameIssueFeedbackLink && sameIssueFeedbackHref ?
<Link
href={sameIssueFeedbackHref}
target="_blank"
onClick={(event) => event.stopPropagation()}
aria-label={`${issue.name} 이슈에 연결된 피드백 보기`}
className="rounded hover:opacity-70"
>
<IssueBadge name={issue.name} status={issue.status} />
</Link>
: <IssueBadge name={issue.name} status={issue.status} />}
</div>
);
})}
</div>
);
};
export default IssueCell;
@@ -0,0 +1,135 @@
/**
* 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 { createColumnHelper } from '@tanstack/react-table';
import dayjs from 'dayjs';
import { Badge, Icon, Tag } from '@ufb/react';
import { DATE_TIME_FORMAT, displayString, RowDragHandleCell } from '@/shared';
import { FIELD_PROPERTY_TEXT } from './field-utils';
import {
DEFAULT_FIELD_KEYS,
FIELD_FORMAT_ICON_MAP,
FIELD_STATUS_COLOR_MAP,
} from './field.constant';
import type { FieldInfo } from './field.type';
import { AiFieldOptionPopover, SelectOptionListPopover } from './ui';
const columnHelper = createColumnHelper<FieldInfo>();
export const getFieldColumns = (reorder?: (data: FieldInfo[]) => void) =>
[
reorder ?
columnHelper.display({
id: 'drag-handle',
cell: ({ row }) => <RowDragHandleCell rowId={row.id} />,
size: 30,
enableSorting: false,
meta: { truncate: false },
})
: null,
columnHelper.accessor('key', {
header: 'Key',
cell: ({ getValue }) => (
<div className="flex items-center gap-1">
<span>{displayString(getValue())}</span>
{DEFAULT_FIELD_KEYS.includes(
getValue() as (typeof DEFAULT_FIELD_KEYS)[number],
) && <Badge variant="outline">Default</Badge>}
</div>
),
enableSorting: false,
}),
columnHelper.accessor('name', {
header: 'Display Name',
cell: ({ getValue }) => <span>{displayString(getValue())}</span>,
enableSorting: false,
}),
columnHelper.accessor('format', {
header: 'Format',
cell: ({ getValue }) => (
<div className="flex items-center gap-1">
<Icon name={FIELD_FORMAT_ICON_MAP[getValue()]} size={16} />
{getValue()}
</div>
),
enableSorting: false,
}),
columnHelper.display({
header: 'Option',
cell: ({ row }) => {
if (
(row.original.format === 'select' ||
row.original.format === 'multiSelect') &&
(row.original.options ?? []).length > 0
) {
return (
<SelectOptionListPopover options={row.original.options ?? []} />
);
}
if (row.original.format === 'aiField') {
return (
<AiFieldOptionPopover
aiFieldTemplateId={row.original.aiFieldTemplateId}
/>
);
}
return '-';
},
enableSorting: false,
meta: { truncate: false },
}),
columnHelper.accessor('property', {
header: 'Property',
cell: ({ getValue }) => (
<Tag variant="secondary" radius="large">
{FIELD_PROPERTY_TEXT[getValue()]}
</Tag>
),
enableSorting: false,
filterFn: (row, _, value: string[]) => {
return value.some((property) => property === row.getValue('property'));
},
meta: { truncate: false },
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ getValue }) => (
<Badge color={FIELD_STATUS_COLOR_MAP[getValue()]} radius="large">
{getValue()}
</Badge>
),
enableSorting: false,
filterFn: (row, _, value: string[]) => {
return value.some((status) => status === row.getValue('status'));
},
meta: { truncate: false },
}),
columnHelper.accessor('description', {
header: 'Description',
cell: ({ getValue }) => <span>{displayString(getValue())}</span>,
enableSorting: false,
}),
columnHelper.accessor('createdAt', {
header: 'Created',
cell: ({ getValue }) => (
<span>
{getValue() ? dayjs(getValue()).format(DATE_TIME_FORMAT) : '-'}
</span>
),
}),
].filter((v) => !!v);
@@ -0,0 +1,30 @@
/**
* 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 { DEFAULT_FIELD_KEYS } from './field.constant';
import type { FieldInfo, FieldProperty } from './field.type';
export const isDefaultField = (field: FieldInfo) =>
DEFAULT_FIELD_KEYS.includes(field.key as (typeof DEFAULT_FIELD_KEYS)[number]);
export const sortField = (a: FieldInfo, b: FieldInfo) => {
return a.order - b.order;
};
export const FIELD_PROPERTY_TEXT: Record<FieldProperty, string> = {
READ_ONLY: 'Read Only',
EDITABLE: 'Editable',
};
@@ -0,0 +1,69 @@
/**
* 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 { Color, IconNameType } from '@ufb/react';
import type { Field, FieldFormat } from './field.type';
export const FIELD_FORMAT_LIST = [
'text',
'keyword',
'number',
'date',
'select',
'multiSelect',
'images',
'aiField',
] as const;
export const FIELD_FORMAT_ICON_MAP: Record<FieldFormat, IconNameType> = {
text: 'RiText',
keyword: 'RiFontSize',
number: 'RiHashtag',
date: 'RiCalendarEventLine',
select: 'RiCheckboxCircleLine',
multiSelect: 'RiListCheck',
images: 'RiImageLine',
aiField: 'RiSparklingFill',
};
export const FIELD_STATUS_COLOR_MAP: Record<Field['status'], Color> = {
ACTIVE: 'green',
INACTIVE: 'red',
};
// The key select is intentionally not a default field. It is a custom field
// that can be edited or removed from channels where it was created accidentally.
export const DEFAULT_FIELD_KEYS = [
'id',
'createdAt',
'updatedAt',
'issues',
'title',
'contents',
'priority',
'IP',
'MAC_address',
'Category',
'requester_id',
'requester_tenant_id',
'requester_email',
'requester_name',
'requester_department',
'is_secret',
'feedback_status',
'assignee',
] as const;
+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 { i18n } from 'next-i18next';
import { z } from 'zod';
import { FIELD_FORMAT_LIST } from './field.constant';
export const fieldOptionSchema = z.object({
id: z.number(),
key: z
.string()
.trim()
.min(1, { message: i18n?.t('hint.required') })
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
name: z
.string()
.trim()
.min(1, { message: i18n?.t('hint.required') })
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
});
export const fieldSchema = z.object({
id: z.number(),
key: z
.string()
.trim()
.min(1, { message: i18n?.t('hint.required') })
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) })
.regex(
/^[A-Za-z0-9_]*$/,
'String can only contain letters, numbers, and underscores, with no spaces or other special characters.',
),
name: z
.string()
.trim()
.min(1, { message: i18n?.t('hint.required') })
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
description: z
.string()
.trim()
.max(50, { message: i18n?.t('hint.max-length', { length: 50 }) })
.nullable(),
format: z.enum(FIELD_FORMAT_LIST),
property: z.enum(['READ_ONLY', 'EDITABLE']),
status: z.enum(['ACTIVE', 'INACTIVE']),
options: z.array(fieldOptionSchema).optional(),
createdAt: z.string(),
updatedAt: z.string(),
order: z.number(),
aiFieldTemplateId: z.number().nullable().optional(),
aiFieldTargetKeys: z.array(z.string()).nullable().optional(),
aiFieldAutoProcessing: z.boolean().nullable().optional(),
});
export const fieldInfoSchema = fieldSchema
.pick({
key: true,
name: true,
description: true,
format: true,
property: true,
status: true,
options: true,
order: true,
aiFieldTemplateId: true,
aiFieldTargetKeys: true,
aiFieldAutoProcessing: true,
})
.merge(
z.object({
id: z.number().optional(),
createdAt: z.string().optional(),
options: z
.array(
fieldOptionSchema
.pick({ key: true, name: true })
.merge(z.object({ id: z.number().optional() })),
)
.optional(),
}),
)
.refine(
(data) =>
data.format === 'select' || data.format === 'multiSelect' ?
!!data.options && data.options.length > 0
: true,
{
path: ['options'],
message: 'Option is required.',
},
)
.refine(
(data) => (data.format === 'aiField' ? !!data.aiFieldTemplateId : true),
{
path: ['aiFieldTemplateId'],
message: 'Template is required.',
},
)
.refine(
(data) =>
data.format === 'aiField' ?
!!data.aiFieldTargetKeys && data.aiFieldTargetKeys.length > 0
: true,
{
path: ['aiFieldTargetKeys'],
message: 'Target Fields are required.',
},
);
+34
View File
@@ -0,0 +1,34 @@
/**
* 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 { z } from 'zod';
import type {
fieldInfoSchema,
fieldOptionSchema,
fieldSchema,
} from './field.schema';
export type Field = z.infer<typeof fieldSchema>;
export type FieldInfo = z.infer<typeof fieldInfoSchema>;
export type FieldFormat = Field['format'];
export type FieldProperty = Field['property'];
export type FieldStatus = Field['status'];
export type FieldOption = z.infer<typeof fieldOptionSchema>;
export type FieldOptionInfo = Omit<FieldOption, 'id'> & {
id?: number;
};
+18
View File
@@ -0,0 +1,18 @@
/**
* 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.
*/
export * from './field.type';
export * from './ui';
export * from './field-utils';
@@ -0,0 +1,63 @@
/**
* 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 { useRouter } from 'next/router';
import {
Caption,
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
Icon,
Tag,
} from '@ufb/react';
import { useOAIQuery } from '@/shared';
interface Props {
aiFieldTemplateId?: number | null;
}
const AiFieldOptionPopover = ({ aiFieldTemplateId }: Props) => {
const router = useRouter();
const projectId = +(router.query.projectId as string);
const { data } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/fieldTemplates',
variables: { projectId },
});
const aiFieldTemplate = data?.find((v) => v.id === aiFieldTemplateId);
if (!aiFieldTemplate) {
return <Caption variant="error">Template is not found</Caption>;
}
return (
<Dropdown>
<DropdownTrigger asChild data-state="close">
<Tag onClick={(e) => e.stopPropagation()}>
Template Option
<Icon name="RiInformation2Line" />
</Tag>
</DropdownTrigger>
<DropdownContent>
<DropdownItem>{aiFieldTemplate.title}</DropdownItem>
</DropdownContent>
</Dropdown>
);
};
export default AiFieldOptionPopover;
@@ -0,0 +1,133 @@
/**
* 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.
*/
/* eslint-disable no-useless-escape */
import { useMemo } from 'react';
import { useTranslation } from 'next-i18next';
import {
Button,
Icon,
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from '@ufb/react';
import { useOAIQuery } from '@/shared';
import { env } from '@/env';
interface IProps {
channelId: number;
projectId: number;
}
const FeedbackRequestCodePopover: React.FC<IProps> = (props) => {
const { channelId, projectId } = props;
const { t } = useTranslation();
const { data: channelData } = useOAIQuery({
path: '/api/admin/projects/{projectId}/channels/{channelId}',
variables: { channelId, projectId },
});
const { data: apiKeysData } = useOAIQuery({
path: '/api/admin/projects/{projectId}/api-keys',
variables: { projectId },
});
const apiKey = useMemo(
() => apiKeysData?.items.find((v) => !v.deletedAt) ?? null,
[apiKeysData],
);
const snippetBody = useMemo(() => {
if (!channelData?.fields) return null;
const body: Record<string, unknown> = {};
for (const field of channelData.fields) {
if (field.status === 'INACTIVE') continue;
if (field.key === 'id') continue;
if (field.key === 'updatedAt') continue;
const key = field.key === 'issues' ? `'issueNames'` : `'${field.key}'`;
switch (field.format) {
case 'number':
case 'date':
body[key] = field.format;
break;
case 'keyword':
case 'text':
body[key] = 'string';
break;
case 'select':
body[key] = field.options.map((v) => `'${v.name}'`).join(' or ');
break;
case 'multiSelect':
body[key] = `[${field.options.map((v) => `'${v.name}'`).join(', ')}]`;
break;
case 'images':
body[key] = `URL[]`;
break;
default:
break;
}
}
return body;
}, [channelData]);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">
<Icon name="RiCodeBlock" />
{t('main.setting.feedback-request-code')}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col gap-5 p-5">
<div className="flex items-center justify-between">
<h5 className="text-title-h5">
{t('main.setting.feedback-request-code')}
</h5>
<PopoverClose asChild>
<Button variant="ghost">
<Icon name="RiCloseLine" />
</Button>
</PopoverClose>
</div>
{!apiKey && (
<p className="text-tint-red">
No API KEY with Active status. Please create API KEY.
</p>
)}
<pre className="bg-neutral-tertiary text-small-normal whitespace-pre-wrap rounded p-4">
{`curl --request POST ${
env.NEXT_PUBLIC_API_BASE_URL
}/api/projects/${projectId}/channels/${channelId}/feedbacks \\\n--header 'Content-Type: application/json' \\\n--header 'x-api-key: ${
apiKey?.value ?? 'API_KEY'
}' \\\n--data-raw '${
snippetBody ?
JSON.stringify(snippetBody, null, 4)
.replace(/\"/g, '')
.replace(/\'/g, '"')
: ''
}'`}
</pre>
</PopoverContent>
</Popover>
);
};
export default FeedbackRequestCodePopover;
@@ -0,0 +1,35 @@
/**
* 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 { Icon } from '@ufb/react';
import { FIELD_FORMAT_ICON_MAP } from '../field.constant';
import type { FieldFormat } from '../field.type';
interface Props {
name: string;
format: FieldFormat;
}
const FieldFormatLabel = ({ name, format }: Props) => {
return (
<div className="flex items-center gap-1">
<Icon name={FIELD_FORMAT_ICON_MAP[format]} size={16} />
{name}
</div>
);
};
export default FieldFormatLabel;
@@ -0,0 +1,483 @@
/**
* 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 Link from 'next/link';
import { useRouter } from 'next/router';
import { zodResolver } from '@hookform/resolvers/zod';
import { useOverlay } from '@toss/use-overlay';
import { useTranslation } from 'next-i18next';
import { FormProvider, useForm } from 'react-hook-form';
import {
Button,
Caption,
Checkbox,
FormField,
Icon,
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
Switch,
Tag,
} from '@ufb/react';
import type { FormOverlayProps } from '@/shared';
import {
Card,
CardDescription,
CardHeader,
CardTitle,
DeleteDialog,
Path,
SelectInput,
TextInput,
useOAIQuery,
} from '@/shared';
import {
FormInput,
FormMultiSelect,
FormSelect,
} from '@/shared/ui/form-inputs';
import { isDefaultField as isDefaultFieldKey } from '../field-utils';
import { FIELD_FORMAT_ICON_MAP, FIELD_FORMAT_LIST } from '../field.constant';
import { fieldInfoSchema } from '../field.schema';
import type { FieldInfo } from '../field.type';
const defaultValues: FieldInfo = {
description: '',
format: 'text',
name: '',
key: '',
status: 'ACTIVE',
property: 'READ_ONLY',
order: 0,
aiFieldTargetKeys: null,
aiFieldTemplateId: null,
aiFieldAutoProcessing: null,
};
interface IProps extends FormOverlayProps<FieldInfo> {
fieldRows: FieldInfo[];
}
const FieldSettingSheet: React.FC<IProps> = (props) => {
const {
data,
fieldRows,
close,
isOpen,
onSubmit: onSave,
onClickDelete,
disabledUpdate,
disabledDelete,
} = props;
const router = useRouter();
const projectId = parseInt(router.query.projectId as string, 10);
const { t } = useTranslation();
const [isSameKey, setIsSameKey] = useState(
data ? data.name === data.key : true,
);
const [optionInput, setOptionInput] = useState<string>('');
const overlay = useOverlay();
const isOriginalData = useMemo(() => (data ? !!data.id : false), [data]);
const isEditing = useMemo(() => !!data, [data]);
const isDefaultField = useMemo(
() => (data ? isDefaultFieldKey(data) : false),
[data],
);
const otherFields = useMemo(
() => fieldRows.filter((v) => v.key !== data?.key),
[data, fieldRows],
);
const methods = useForm<FieldInfo>({
resolver: zodResolver(fieldInfoSchema),
defaultValues: data ?? defaultValues,
});
const {
watch,
setValue,
handleSubmit,
reset,
setError,
formState,
clearErrors,
control,
} = methods;
useEffect(() => {
if (isSameKey) setValue('name', watch('key'), { shouldDirty: true });
}, [isSameKey, watch('key')]);
const addOption = () => {
if (optionInput === '') return;
if (
watch('options')?.find(
(v) => v.name.toLowerCase() === optionInput.toLowerCase(),
)
) {
setError('options', { message: 'Option Name is duplicated' });
return;
}
setValue(
'options',
(watch('options') ?? []).concat({
name: optionInput,
key: optionInput,
}),
{ shouldDirty: true },
);
setOptionInput('');
clearErrors('options');
};
const removeOption = (targetIndex: number) => {
overlay.open(({ close, isOpen }) => (
<DeleteDialog
close={close}
isOpen={isOpen}
onClickDelete={() => {
setValue(
'options',
(watch('options') ?? []).filter((_, i) => i !== targetIndex),
{ shouldDirty: true },
);
}}
description={t('main.setting.dialog.delete-option.description')}
/>
));
};
const onSubmit = async (input: FieldInfo) => {
const checkDuplicatedKey = otherFields.find((v) => v.key === input.key);
if (checkDuplicatedKey) {
setError('key', { message: 'Key is duplicated' });
return;
}
const checkDuplicatedName = otherFields.find((v) => v.name === input.name);
if (checkDuplicatedName) {
setError('name', { message: 'Name is duplicated' });
return;
}
if (input.format === 'select' || input.format === 'multiSelect') {
if (!input.options || input.options.length === 0) {
setError('options', { message: 'Option is required' });
return;
}
}
await onSave({ ...data, ...input });
reset(defaultValues);
close();
};
const { data: aiIntegration } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/integrations',
variables: { projectId },
});
const { data: templates } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/fieldTemplates',
variables: { projectId },
});
useEffect(() => {
if (!data) return;
if (data.format === 'aiField' && !data.aiFieldTemplateId) {
setError('aiFieldTemplateId', { message: 'Template is required' });
}
}, [data]);
return (
<Sheet open={isOpen} onOpenChange={close}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{isEditing ?
t('main.setting.dialog.edit-field.title')
: t('main.setting.dialog.add-field.title')}
</SheetTitle>
</SheetHeader>
<SheetBody asChild>
<FormProvider {...methods}>
<form
id="field-setting"
onSubmit={handleSubmit(onSubmit)}
className="flex w-[450px] flex-col gap-4"
>
<FormField
control={control}
name="key"
render={({ field }) => (
<FormInput
label="Key"
{...field}
disabled={isOriginalData || isDefaultField}
required={!data}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormInput
label="Display Name"
{...field}
disabled={isSameKey || isDefaultField}
required
/>
)}
/>
<Checkbox
checked={isSameKey}
onCheckedChange={(checked) => setIsSameKey(!!checked)}
disabled={isDefaultField}
>
{t('main.setting.same-key')}
</Checkbox>
</div>
<div>
<SelectInput
label="Format"
onChange={(value) => {
setValue('format', value as FieldInfo['format'], {
shouldDirty: true,
});
setValue('options', undefined);
setValue('aiFieldTemplateId', undefined);
setValue('aiFieldTargetKeys', undefined);
setValue('aiFieldAutoProcessing', undefined);
}}
options={FIELD_FORMAT_LIST.map((v) => ({
label: v,
value: v,
icon: FIELD_FORMAT_ICON_MAP[v],
disabled: v === 'aiField' && !aiIntegration?.apiKey,
}))}
value={watch('format')}
disabled={isDefaultField}
required
/>
{watch('format') === 'images' && (
<Caption>{t('hint.image-format')}</Caption>
)}
</div>
{(watch('format') === 'select' ||
watch('format') === 'multiSelect') &&
!isDefaultField && (
<div>
<TextInput
label="Select Option"
value={optionInput}
maxLength={20}
onChange={(e) => setOptionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addOption();
}
}}
error={formState.errors.options?.message}
rightButton={
<Button onClick={addOption}>
{t('button.register')}
</Button>
}
required
/>
{(watch('options') ?? []).length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{watch('options')?.map((v, i) => (
<Tag key={i} radius="large" variant="secondary">
{v.name}
<Icon
name="RiCloseLargeLine"
onClick={() => removeOption(i)}
/>
</Tag>
))}
</div>
)}
</div>
)}
{watch('format') === 'aiField' && (
<>
<div>
<FormField
control={control}
name="aiFieldTemplateId"
render={({ field }) => (
<>
<FormSelect
label="Template"
options={
templates?.map(({ title, id }) => ({
label: title,
value: id.toString(),
})) ?? []
}
disabled={isDefaultField}
value={field.value?.toString()}
onChange={(value) => {
field.onChange(value ? parseInt(value) : null);
clearErrors('aiFieldTemplateId');
}}
required
/>
<Link
target="_blank"
href={{
pathname: Path.SETTINGS,
query: {
menu: 'generative-ai',
subMenu: 'field-template',
projectId,
},
}}
>
<Caption className="mt-1 flex items-center gap-0.5">
<Icon name="RiExternalLinkFill" size={16} />
Go to Template Settings
</Caption>
</Link>
</>
)}
/>
</div>
<FormField
control={control}
name="aiFieldTargetKeys"
render={({ field }) => (
<FormMultiSelect
label="Target Field"
options={fieldRows
.filter(({ key }) => key !== watch('key'))
.map(({ key, name }) => ({
label: name,
value: key,
}))}
{...field}
disabled={isDefaultField}
required
/>
)}
/>
<Card size="sm">
<CardHeader
action={
<Switch
checked={watch('aiFieldAutoProcessing') ?? false}
onCheckedChange={(checked) =>
setValue('aiFieldAutoProcessing', checked, {
shouldDirty: true,
})
}
/>
}
>
<CardTitle>AI Field Automation</CardTitle>
<CardDescription>
{t('v2.description.ai-field-template-auto-processing')}
</CardDescription>
</CardHeader>
</Card>
</>
)}
<FormField
control={control}
name="property"
render={({ field }) => (
<FormSelect
label="Property"
options={[
{ label: 'Read Only', value: 'READ_ONLY' },
{ label: 'Editable', value: 'EDITABLE' },
]}
{...field}
disabled={isDefaultField}
required
/>
)}
/>
<FormField
control={control}
name="status"
render={({ field }) => (
<FormSelect
label="Status"
options={[
{ label: 'Active', value: 'ACTIVE' },
{ label: 'Inactive', value: 'INACTIVE' },
]}
{...field}
disabled={isDefaultField}
required
/>
)}
/>
<FormField
control={control}
name="description"
render={({ field }) => (
<FormInput
label="Description"
{...field}
disabled={isDefaultField}
/>
)}
/>
</form>
</FormProvider>
</SheetBody>
<SheetFooter>
{data && (
<div className="flex-1">
<Button
disabled={isDefaultField || disabledDelete}
variant="destructive"
onClick={() => onClickDelete?.()}
>
{t('v2.button.delete')}
</Button>
</div>
)}
<SheetClose>{t('v2.button.cancel')}</SheetClose>
<Button
type="submit"
form="field-setting"
disabled={isDefaultField || !formState.isDirty || disabledUpdate}
>
{t('v2.button.confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
};
export default FieldSettingSheet;
@@ -0,0 +1,95 @@
/**
* 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 } from 'react';
import {
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Button, Icon } from '@ufb/react';
import { BasicTable, TableFacetedFilter } from '@/shared';
import { getFieldColumns } from '../field-columns';
import type { FieldInfo } from '../field.type';
interface IProps {
fields: FieldInfo[];
onClickRow?: (index: number, field: FieldInfo) => void;
reorder?: (data: FieldInfo[]) => void;
disableFilter?: boolean;
}
const FieldTable: React.FC<IProps> = (props) => {
const { fields, onClickRow, reorder, disableFilter } = props;
const columns = useMemo(() => getFieldColumns(reorder), []);
const table = useReactTable({
columns,
data: fields.sort((a, b) => a.order - b.order),
enableColumnFilters: true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => String(row.key),
});
return (
<div className="flex h-full flex-col gap-4">
{!disableFilter && (
<div className="flex gap-3">
<TableFacetedFilter
column={table.getColumn('status')}
options={[
{ label: 'Inactive', value: 'INACTIVE' },
{ label: 'Active', value: 'ACTIVE' },
]}
title="Status"
/>
<TableFacetedFilter
column={table.getColumn('property')}
options={[
{ label: 'Editable', value: 'EDITABLE' },
{ label: 'Read Only', value: 'READ_ONLY' },
]}
title="Property"
/>
{table.getState().columnFilters.length > 0 && (
<Button variant="ghost" onClick={() => table.resetColumnFilters()}>
<Icon name="RiCloseLine" />
Reset
</Button>
)}
</div>
)}
<BasicTable
table={table}
onClickRow={(index, row) => onClickRow?.(index, row)}
reorder={(data) =>
reorder?.(
data.map((field, index) => ({ ...field, order: index + 1 })),
)
}
/>
</div>
);
};
export default FieldTable;
+22
View File
@@ -0,0 +1,22 @@
/**
* 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.
*/
export { default as FieldTable } from './field-table.ui';
export { default as FieldSettingSheet } from './field-setting-sheet.ui';
export { default as FeedbackRequestCodePopover } from './feedback-request-code-popover.ui';
export { default as PreviewFieldTable } from './preview-field-table.ui';
export { default as FieldFormatLabel } from './field-format-label.ui';
export { default as AiFieldOptionPopover } from './ai-field-option-popover.ui';
export { default as SelectOptionListPopover } from './select-option-list-popover.ui';
@@ -0,0 +1,53 @@
/**
* 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 { memo, useMemo } from 'react';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { BasicTable } from '@/shared';
import { usePreviewFeedback } from '@/entities/feedback';
import { getColumns } from '@/entities/feedback/feedback-table-columns';
import type { FieldInfo } from '../field.type';
interface IProps {
fields: FieldInfo[];
}
const PreviewFieldTable: React.FC<IProps> = ({ fields }) => {
const columns = useMemo(
() => getColumns(fields, true).filter((v) => v.id !== 'feedback-checkbox'),
[fields],
);
const feedbacks = usePreviewFeedback(fields);
const table = useReactTable({
columns,
data: feedbacks,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => String(row.id),
});
return (
<div className="h-full overflow-auto">
<BasicTable table={table} className="table-fixed" />
</div>
);
};
export default memo(
PreviewFieldTable,
(prev, next) => JSON.stringify(prev.fields) === JSON.stringify(next.fields),
);
@@ -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 {
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
Icon,
Tag,
} from '@ufb/react';
import type { FieldOptionInfo } from '../field.type';
interface IProps {
options: FieldOptionInfo[];
}
const SelectOptionListPopover: React.FC<IProps> = ({ options }) => {
return (
<Dropdown>
<DropdownTrigger asChild data-state="close">
<Tag onClick={(e) => e.stopPropagation()}>
Select Options
<Icon name="RiInformation2Line" />
</Tag>
</DropdownTrigger>
<DropdownContent>
{options.map((v) => (
<DropdownItem key={v.key}>{v.name}</DropdownItem>
))}
</DropdownContent>
</Dropdown>
);
};
export default SelectOptionListPopover;
@@ -0,0 +1,18 @@
/**
* 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.
*/
export * from './ui';
export * from './issue-tracker.type';
export * from './issue-tracker.schema';
@@ -0,0 +1,22 @@
/**
* 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 { z } from 'zod';
export const issueTrackerSchema = z.object({
provider: z.enum(['jira', 'gitea', 'github']),
ticketDomain: z.string().trim().url().nullable().or(z.literal('')),
ticketKey: z.string().trim().nullable(),
});
@@ -0,0 +1,20 @@
/**
* 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 { z } from 'zod';
import type { issueTrackerSchema } from './issue-tracker.schema';
export type IssueTracker = z.infer<typeof issueTrackerSchema>;
@@ -0,0 +1,16 @@
/**
* 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.
*/
export { default as IssueTrackerForm } from './issue-tracker-form.ui';
@@ -0,0 +1,97 @@
/**
* 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 { useFormContext } from 'react-hook-form';
import { Icon, InputField, Label } from '@ufb/react';
import { cn, SelectInput, TextInput } from '@/shared';
import type { IssueTracker } from '../issue-tracker.type';
interface IProps {
readOnly?: boolean;
}
const IssueTrackerForm: React.FC<IProps> = ({ readOnly }) => {
const { register, setValue, watch, formState } =
useFormContext<IssueTracker>();
const provider = watch('provider');
const isRepositoryProvider = provider === 'gitea' || provider === 'github';
return (
<div className="flex flex-col gap-4">
<SelectInput
options={[
{ value: 'jira', label: 'Jira' },
{ value: 'gitea', label: 'Gitea' },
{ value: 'github', label: 'GitHub' },
]}
value={provider}
onChange={(value) => {
if (value === 'jira' || value === 'gitea' || value === 'github') {
setValue('provider', value, { shouldDirty: true });
}
}}
label="Issue Tracking System"
disabled={readOnly}
/>
<TextInput
label={isRepositoryProvider ? 'Repository URL' : 'Base URL'}
placeholder={
isRepositoryProvider ?
provider === 'github' ?
'https://github.com/owner/repository'
: 'https://gitea.hmac.kr/owner/repository'
: 'https://example.com'
}
error={formState.errors.ticketDomain?.message}
{...register('ticketDomain')}
/>
{provider === 'jira' && (
<TextInput
label="Project Key"
placeholder="PROJ"
error={formState.errors.ticketKey?.message}
disabled={readOnly}
{...register('ticketKey')}
/>
)}
<InputField>
<Label>Preview</Label>
<div
className={cn(
'bg-neutral-tertiary flex items-center gap-2 rounded p-4',
{
'opacity-50':
!watch('ticketDomain') ||
(provider === 'jira' && !watch('ticketKey')),
},
)}
>
<Icon name="RiPriceTag3Fill" size={16} />
{isRepositoryProvider ?
`${watch('ticketDomain') ?? (provider === 'github' ? 'https://github.com/owner/repository' : 'https://gitea.hmac.kr/owner/repository')}/issues/{Number}`
: `${watch('ticketDomain') ?? 'https://example.com'}/browse/${watch('ticketKey') ?? 'PROJ'}-{Number}`
}
</div>
</InputField>
</div>
);
};
export default IssueTrackerForm;
+18
View File
@@ -0,0 +1,18 @@
/**
* 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.
*/
export * from './issue.type';
export * from './ui';
export * from './lib';
@@ -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 { z } from 'zod';
import { categorySchema } from '../category/category.schema';
export const issueSchema = z.object({
id: z.number(),
name: z.string().trim().min(1).max(30),
description: z.string().trim().max(50).nullable(),
feedbackCount: z.number(),
issueAdminUserId: z.number().nullable().optional(),
status: z.union([
z.literal('INIT'),
z.literal('ON_REVIEW'),
z.literal('DETAILED_REVIEW'),
z.literal('IN_PROGRESS'),
z.literal('RESOLVED'),
z.literal('PENDING'),
]),
externalIssueId: z.string().trim().optional(),
externalIssueUrl: z.string().url().nullable().optional(),
externalIssueStatus: z.string().nullable().optional(),
externalIssueSyncStatus: z.string().optional(),
externalIssueSyncError: z.string().nullable().optional(),
externalIssueSyncedAt: z.string().nullable().optional(),
createdAt: z.string(),
updatedAt: z.string().nullable(),
category: categorySchema.nullable(),
});
export const issueFormSchema = issueSchema.pick({
name: true,
description: true,
status: true,
externalIssueId: true,
});
+22
View File
@@ -0,0 +1,22 @@
/**
* 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 { z } from 'zod';
import type { issueFormSchema, issueSchema } from './issue.schema';
export type Issue = z.infer<typeof issueSchema>;
export type IssueStatus = Issue['status'];
export type IssueFormSchema = z.infer<typeof issueFormSchema>;
+17
View File
@@ -0,0 +1,17 @@
/**
* 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.
*/
export { default as useIssueSearch } from './use-issue-search';
export { default as useIssueSearchInfinite } from './use-issue-search-infinite';

Some files were not shown because too many files have changed in this diff Show More