first commit
CI / typecheck (push) Successful in 1m8s
CI / format (push) Failing after 1m6s
CI / lint (push) Failing after 49s
CI / test (push) Failing after 1m8s

This commit is contained in:
SDI
2026-07-15 18:05:12 +09:00
commit 12e4f17b62
4633 changed files with 817125 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,240 @@
/**
* 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;
}
const CategoryCombobox = (props: Props) => {
const { category, issueId, children } = props;
const perms = usePermissions();
const router = useRouter();
const projectId = Number(router.query.projectId);
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';
+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 * 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 useLineChartData } from './use-line-chart-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,147 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { 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',
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,128 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { ColumnDef, DisplayColumnDef } from '@tanstack/react-table';
import { createColumnHelper } from '@tanstack/react-table';
import { 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 type { Feedback } from './feedback.type';
import FeedbackCell from './ui/feedback-cell';
import IssueCell from './ui/issue-cell';
const columnHelper = createColumnHelper<Feedback>();
export const getColumns = (
fieldData: FieldInfo[],
isPreview?: boolean,
): ColumnDef<Feedback>[] =>
[
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,
}),
].concat(
fieldData.map((field) =>
field.key === 'id' ?
columnHelper.accessor('id', {
id: 'id',
size: 100,
minSize: 100,
header: () => (
<div className="flex items-center gap-1">
<Icon name={FIELD_FORMAT_ICON_MAP.number} size={16} />
{fieldData.find((v) => v.key === 'id')?.name}
</div>
),
cell: (info) => (
<ExpandableText isExpanded={info.row.getIsExpanded()}>
{info.getValue() as number}
</ExpandableText>
),
enableSorting: false,
})
: field.key === 'issues' ?
columnHelper.accessor('issues', {
id: 'issues',
size: 150,
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}
/>
</div>
</div>
),
enableSorting: false,
})
: columnHelper.accessor(field.key, {
id: field.key,
size: 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>[],
);
@@ -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>;
+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 './lib';
export * from './ui';
export * from './feedback.type';
@@ -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,156 @@
/**
* 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 '..';
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 FeedbackCell: React.FC<IProps> = memo((props) => {
const { isExpanded, field, value, feedbackId } = props;
const renderableText = toRenderableText(value);
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="subtle">
{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(),
},
}}
>
{renderableText}
</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;
@@ -0,0 +1,581 @@
/**
* 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 { useOverlay } from '@toss/use-overlay';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import {
Button,
Divider,
Icon,
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
Tag,
toast,
} from '@ufb/react';
import {
DeleteDialog,
SUPPORT_TEST_ADMIN_USER_EMAIL,
SUPPORT_TEST_TENANT_ID,
getSupportWorkspaceCodeByProjectChannel,
isObjectEqual,
SheetDetailTable,
usePermissions,
} from '@/shared';
import type { SheetDetailTableRow } from '@/shared/ui/sheet-detail-table.ui';
import type { FieldInfo } from '@/entities/field';
import { DEFAULT_FIELD_KEYS } from '@/entities/field/field.constant';
import type { Feedback } from '../feedback.type';
interface Props {
isOpen: boolean;
close: () => void;
fields: FieldInfo[];
feedback: Feedback;
onClickDelete?: () => Promise<unknown>;
updateFeedback?: (feedback: Feedback) => Promise<unknown>;
projectId: number;
channelId: number;
refetchFeedback?: () => Promise<unknown>;
}
interface SupportTicketCommentRecord {
comment_id: number;
ticket_id: number;
author_id: string;
author_tenant_id: string;
author_name: string;
content: string;
is_internal: boolean;
created_at: string;
updated_at: string;
edited_at: string | null;
}
const FeedbackDetailSheet = (props: Props) => {
const {
close,
feedback,
fields,
isOpen,
onClickDelete,
projectId,
updateFeedback,
channelId,
refetchFeedback,
} = props;
const { t } = useTranslation();
const perms = usePermissions();
const [currentFeedback, setCurrentFeedback] = useState(feedback);
const [mode, setMode] = useState<'edit' | 'view'>('view');
const overlay = useOverlay();
const [isLoading, setIsLoading] = useState(false);
const [comments, setComments] = useState<SupportTicketCommentRecord[]>([]);
const [commentDraft, setCommentDraft] = useState('');
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [editingCommentDraft, setEditingCommentDraft] = useState('');
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
const [isCommentSaving, setIsCommentSaving] = useState(false);
const feedbackId = Number(feedback.id);
const workspaceCode = useMemo(
() => getSupportWorkspaceCodeByProjectChannel(projectId, channelId),
[channelId, projectId],
);
useEffect(() => {
setCurrentFeedback(feedback);
}, [feedback]);
useEffect(() => {
if (!isOpen || !workspaceCode || !Number.isFinite(feedbackId)) {
setComments([]);
return;
}
const fetchComments = async () => {
setIsCommentsLoading(true);
try {
const response = await fetch(
`/api/support/tickets/${feedbackId}/comments?workspaceCode=${workspaceCode}`,
);
const data = (await response.json()) as
| SupportTicketCommentRecord[]
| { message?: string };
if (!response.ok) {
throw new Error(
'message' in data && data.message ? data.message : '댓글을 불러오지 못했습니다.',
);
}
setComments(data as SupportTicketCommentRecord[]);
} catch (error) {
toast.error(
error instanceof Error ? error.message : '댓글 조회 중 오류가 발생했습니다.',
);
} finally {
setIsCommentsLoading(false);
}
};
void fetchComments();
}, [feedbackId, isOpen, workspaceCode]);
const onClickCancel = () => {
setMode('view');
setCurrentFeedback(feedback);
};
const onClickSubmit = async () => {
const editedFeedback = fields
.filter((v) => v.property === 'EDITABLE' && v.status === 'ACTIVE')
.reduce((acc, cur) => {
if (cur.key === 'issues') return acc;
if (cur.format === 'date') {
return {
...acc,
[cur.key]:
currentFeedback[cur.key] ?
dayjs(currentFeedback[cur.key] as string).toISOString()
: null,
};
}
if (cur.format === 'multiSelect') {
return {
...acc,
[cur.key]: currentFeedback[cur.key] ?? [],
};
}
if (cur.format === 'select') {
return {
...acc,
[cur.key]: currentFeedback[cur.key] ?? null,
};
}
return { ...acc, [cur.key]: currentFeedback[cur.key] };
}, {} as Feedback);
try {
await updateFeedback?.(editedFeedback);
} finally {
setIsLoading(false);
setMode('view');
}
};
const openDeleteDialog = () => {
overlay.open(({ close: dialogClose, isOpen }) => (
<DeleteDialog
close={dialogClose}
isOpen={isOpen}
onClickDelete={async () => {
await onClickDelete?.();
dialogClose();
close();
}}
/>
));
};
const handleCreateComment = async () => {
if (!workspaceCode || !Number.isFinite(feedbackId) || isCommentSaving) {
return;
}
const nextContent = commentDraft.trim();
if (!nextContent) {
toast.error('댓글 내용을 입력해 주세요.');
return;
}
setIsCommentSaving(true);
try {
const response = await fetch(
`/api/support/tickets/${feedbackId}/comments?workspaceCode=${workspaceCode}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
requester_id: SUPPORT_TEST_ADMIN_USER_EMAIL,
requester_tenant_id: SUPPORT_TEST_TENANT_ID,
author_name: 'Admin User',
content: nextContent,
is_internal: true,
}),
},
);
const data = (await response.json()) as SupportTicketCommentRecord | { message?: string };
if (!response.ok) {
throw new Error(
'message' in data && data.message ? data.message : '댓글 저장에 실패했습니다.',
);
}
setComments((current) => [...current, data as SupportTicketCommentRecord]);
setCommentDraft('');
toast.success('관리자 댓글이 저장되었습니다.');
} catch (error) {
toast.error(
error instanceof Error ? error.message : '댓글 저장 중 오류가 발생했습니다.',
);
} finally {
setIsCommentSaving(false);
}
};
const handleUpdateComment = async (commentId: number) => {
if (!workspaceCode || !Number.isFinite(feedbackId) || isCommentSaving) {
return;
}
const nextContent = editingCommentDraft.trim();
if (!nextContent) {
toast.error('댓글 내용을 입력해 주세요.');
return;
}
setIsCommentSaving(true);
try {
const response = await fetch(
`/api/support/tickets/${feedbackId}/comments/${commentId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
requester_id: SUPPORT_TEST_ADMIN_USER_EMAIL,
requester_tenant_id: SUPPORT_TEST_TENANT_ID,
content: nextContent,
}),
},
);
const data = (await response.json()) as SupportTicketCommentRecord | { message?: string };
if (!response.ok) {
throw new Error(
'message' in data && data.message ? data.message : '댓글 수정에 실패했습니다.',
);
}
setComments((current) =>
current.map((comment) =>
comment.comment_id === commentId ? (data as SupportTicketCommentRecord) : comment,
),
);
setEditingCommentId(null);
setEditingCommentDraft('');
toast.success('관리자 댓글이 수정되었습니다.');
} catch (error) {
toast.error(
error instanceof Error ? error.message : '댓글 수정 중 오류가 발생했습니다.',
);
} finally {
setIsCommentSaving(false);
}
};
const handleDeleteComment = async (commentId: number) => {
if (!workspaceCode || !Number.isFinite(feedbackId) || isCommentSaving) {
return;
}
if (!window.confirm('이 댓글을 삭제하시겠습니까?')) {
return;
}
setIsCommentSaving(true);
try {
const query = new URLSearchParams({
requesterId: SUPPORT_TEST_ADMIN_USER_EMAIL,
requesterTenantId: SUPPORT_TEST_TENANT_ID,
});
const response = await fetch(
`/api/support/tickets/${feedbackId}/comments/${commentId}?${query.toString()}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
const data = (await response.json()) as { message?: string };
throw new Error(data.message ?? '댓글 삭제에 실패했습니다.');
}
setComments((current) =>
current.filter((comment) => comment.comment_id !== commentId),
);
if (editingCommentId === commentId) {
setEditingCommentId(null);
setEditingCommentDraft('');
}
toast.success('관리자 댓글이 삭제되었습니다.');
} catch (error) {
toast.error(
error instanceof Error ? error.message : '댓글 삭제 중 오류가 발생했습니다.',
);
} finally {
setIsCommentSaving(false);
}
};
return (
<Sheet open={isOpen} onOpenChange={close}>
<SheetContent className="max-w-[800px]">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
{t('v2.text.name.detail', { name: 'Feedback' })}
<Tag
variant="outline"
size="small"
className="cursor-pointer"
onClick={async () => {
await navigator.clipboard.writeText(
`${window.location.origin}/${window.location.pathname}?queries=${JSON.stringify([{ key: 'id', value: feedback.id, condition: 'IS' }])}&channelId=${channelId}`,
);
toast(t('v2.toast.copy'), {
icon: <Icon name="RiCheckboxMultipleFill" />,
});
}}
>
Copy URL
</Tag>
</SheetTitle>
</SheetHeader>
<SheetBody>
<SheetDetailTable
data={currentFeedback}
rows={
fields
.filter((v) => DEFAULT_FIELD_KEYS.includes(v.key))
.map((v) =>
v.key === 'issues' ?
{ ...v, format: 'issue', feedbackId: feedback.id }
: v.format === 'aiField' ?
{ ...v, format: 'aiField', refetch: refetchFeedback }
: v,
) as SheetDetailTableRow[]
}
/>
<Divider variant="subtle" className="my-4" />
<SheetDetailTable
rows={
fields
.filter((v) => !DEFAULT_FIELD_KEYS.includes(v.key))
.map((v) => ({
...v,
editable: v.property === 'EDITABLE',
disabled:
v.property === 'EDITABLE' && v.status === 'INACTIVE',
refetch: v.format === 'aiField' ? refetchFeedback : undefined,
})) as SheetDetailTableRow[]
}
mode={mode}
data={currentFeedback}
onChange={(key, value) =>
setCurrentFeedback((prev) => ({ ...prev, [key]: value }))
}
/>
{workspaceCode && Number.isFinite(feedbackId) && (
<>
<Divider variant="subtle" className="my-4" />
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">Comments</p>
<p className="text-xs text-neutral-secondary">
ABC .
</p>
</div>
<Tag variant="outline" size="small">
{comments.length} items
</Tag>
</div>
{isCommentsLoading ? (
<div className="text-sm text-neutral-secondary"> ...</div>
) : comments.length === 0 ? (
<div className="rounded-xl border border-dashed border-neutral-border p-4 text-sm text-neutral-secondary">
.
</div>
) : (
<div className="space-y-3">
{comments
.slice()
.reverse()
.map((comment) => {
const isOwnInternalComment =
comment.is_internal &&
comment.author_id === SUPPORT_TEST_ADMIN_USER_EMAIL &&
comment.author_tenant_id === SUPPORT_TEST_TENANT_ID;
return (
<div key={comment.comment_id} className="rounded-xl border border-neutral-border bg-neutral-background p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{comment.author_name}</p>
<Tag variant={comment.is_internal ? 'secondary' : 'outline'} size="small">
{comment.is_internal ? 'Internal' : 'User'}
</Tag>
</div>
<div className="flex items-center gap-3">
<p className="text-xs text-neutral-secondary">
{dayjs(comment.created_at).format('YYYY-MM-DD HH:mm')}
</p>
{isOwnInternalComment && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setEditingCommentId(comment.comment_id);
setEditingCommentDraft(comment.content);
}}
className="text-xs font-medium text-primary"
>
</button>
<button
type="button"
onClick={() => void handleDeleteComment(comment.comment_id)}
className="text-xs font-medium text-tint-red"
>
</button>
</div>
)}
</div>
</div>
{editingCommentId === comment.comment_id ? (
<div className="mt-3 space-y-3">
<textarea
value={editingCommentDraft}
onChange={(event) => setEditingCommentDraft(event.target.value)}
rows={4}
className="w-full resize-none rounded-lg border border-neutral-border bg-background px-3 py-2 text-sm outline-none transition focus:border-primary"
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setEditingCommentId(null);
setEditingCommentDraft('');
}}
>
</Button>
<Button
onClick={() => void handleUpdateComment(comment.comment_id)}
loading={isCommentSaving}
>
</Button>
</div>
</div>
) : (
<p className="mt-2 whitespace-pre-line text-sm">{comment.content}</p>
)}
</div>
);
})}
</div>
)}
<div className="space-y-2 rounded-xl border border-neutral-border p-4">
<p className="text-sm font-medium"> </p>
<textarea
value={commentDraft}
onChange={(event) => setCommentDraft(event.target.value)}
rows={4}
placeholder="처리 메모나 운영 코멘트를 남겨 주세요."
className="w-full resize-none rounded-lg border border-neutral-border bg-background px-3 py-2 text-sm outline-none transition focus:border-primary"
/>
<div className="flex justify-end">
<Button onClick={handleCreateComment} loading={isCommentSaving}>
</Button>
</div>
</div>
</div>
</>
)}
</SheetBody>
<SheetFooter>
{onClickDelete && (
<div className="flex-1">
<Button
variant="destructive"
onClick={openDeleteDialog}
disabled={!perms.includes('feedback_delete')}
>
{t('v2.button.delete')}
</Button>
</div>
)}
{mode === 'edit' && (
<>
<Button variant="secondary" onClick={onClickCancel}>
{t('v2.button.cancel')}
</Button>
<Button
disabled={isObjectEqual(
{ ...feedback, updatedAt: '' },
{ ...currentFeedback, updatedAt: '' },
)}
onClick={onClickSubmit}
loading={isLoading}
>
{t('v2.button.save')}
</Button>
</>
)}
{mode === 'view' && (
<>
<SheetClose>{t('v2.button.cancel')}</SheetClose>
<Button
onClick={() => setMode('edit')}
disabled={!perms.includes('feedback_update')}
>
{t('v2.button.edit')}
</Button>
</>
)}
</SheetFooter>
</SheetContent>
</Sheet>
);
};
export default FeedbackDetailSheet;
@@ -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,415 @@
/**
* 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 { Badge, Button, Icon, toast } from '@ufb/react';
import type { TableFilterField } from '@/shared';
import {
BasicTable,
DateRangePicker,
DeleteDialog,
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 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 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 [openFeedbackId, setOpenFeedbackId] = useState<number | 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: 20,
});
const [columnVisibility, setColumnVisibility] = useState<
Record<number, VisibilityState>
>({});
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), [fields]);
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,
operator,
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(() => {
setRows(feedbackData?.items ?? []);
setPageCount(feedbackData?.meta.totalPages ?? 0);
setRowCount(feedbackData?.meta.totalItems ?? 0);
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(
() => rows.find((v) => v.id === openFeedbackId),
[rows, openFeedbackId],
);
const { mutateAsync: updateFeedback } = useOAIMutation({
method: 'put',
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/{feedbackId}',
pathParams: {
channelId: currentChannel.id,
feedbackId: (currentFeedback?.id ?? 0) as number,
projectId,
},
queryOptions: {
onSuccess: async () => {
await refetch();
toast.success(t('v2.toast.success'));
},
},
});
return (
<div className="flex h-full flex-col gap-4">
<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')}
/>
<TableFilterPopover
filterFields={filterFields.filter(
(v) =>
v.key === 'issueIds' ||
table.getAllColumns().some((column) => column.id === v.key),
)}
operator={operator}
onSubmit={onChangeTableFilters}
tableFilters={tableFilters}
table={table}
/>
<FeedbackTableViewOptions table={table} fields={fields} />
<FeedbackTableExpand table={table} />
<FeedbackTableDownload
table={table}
fields={fields}
queries={queries}
defaultQueries={defaultQueries}
disabled={!perms.includes('feedback_download_read')}
operator={operator}
totalItems={feedbackData?.meta.totalItems ?? 0}
/>
</div>
</div>
<BasicTable
table={table}
onClickRow={(_, row) => setOpenFeedbackId(row.id as number)}
isLoading={isLoading}
isFiltered={queries.length > 0}
resiable
/>
<TablePagination table={table} />
{currentFeedback && (
<FeedbackDetailSheet
updateFeedback={(feedback) => updateFeedback(feedback as never)}
onClickDelete={() =>
deleteFeedback({ feedbackIds: [currentFeedback.id as number] })
}
isOpen={!!openFeedbackId}
close={() => setOpenFeedbackId(null)}
feedback={currentFeedback}
fields={fields}
projectId={projectId}
channelId={currentChannel.id}
refetchFeedback={refetch}
/>
)}
</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,299 @@
/**
* 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 { 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;
}
const IssueCell: React.FC<IProps> = (props) => {
const { feedbackId, issues, isPreview = 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) => (
<IssueBadge key={issue.id} name={issue.name} status={issue.status} />
))}
</div>
);
};
export default IssueCell;
@@ -0,0 +1,136 @@
/**
* 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 {
FIELD_FORMAT_ICON_MAP,
FIELD_STATUS_COLOR_MAP,
} from './field.constant';
import type { FieldInfo } from './field.type';
import { AiFieldOptionPopover, SelectOptionListPopover } from './ui';
const DEFAULT_FIELD_COLUMNS_KEYS = ['id', 'createdAt', 'updatedAt', 'issues'];
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_COLUMNS_KEYS.includes(getValue()) && (
<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,32 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { FieldInfo, FieldProperty } from './field.type';
export const isDefaultField = (field: FieldInfo) =>
field.key === 'id' ||
field.key === 'createdAt' ||
field.key === 'updatedAt' ||
field.key === 'issues';
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,48 @@
/**
* 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',
};
export const DEFAULT_FIELD_KEYS = ['id', 'createdAt', 'updatedAt', 'issues'];
+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,486 @@
/**
* 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 { 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?.key === 'id' ||
data?.key === 'issues' ||
data?.key === 'createdAt' ||
data?.key === 'updatedAt',
[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={isOriginalData || 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={isOriginalData || 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,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.
*/
import { z } from 'zod';
export const issueTrackerSchema = z.object({
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,70 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { 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, watch, formState } = useFormContext<IssueTracker>();
return (
<div className="flex flex-col gap-4">
<SelectInput
options={[{ value: 'jira', label: 'Jira' }]}
value="jira"
label="Issue Tracking System"
disabled={readOnly}
/>
<TextInput
label="Base URL"
placeholder="https://example.com"
error={formState.errors.ticketDomain?.message}
{...register('ticketDomain')}
/>
<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') || !watch('ticketKey') },
)}
>
<Icon name="RiPriceTag3Fill" size={16} />
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{`${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,44 @@
/**
* 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(),
status: z.union([
z.literal('INIT'),
z.literal('ON_REVIEW'),
z.literal('IN_PROGRESS'),
z.literal('RESOLVED'),
z.literal('PENDING'),
]),
externalIssueId: z.string().trim().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';
@@ -0,0 +1,82 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useInfiniteQuery } from '@tanstack/react-query';
import type {
OAIMutationResponse,
OAIRequestBody,
SearchQuery,
} from '@/shared';
import { client } from '@/shared';
type TData = OAIMutationResponse<
'/api/admin/projects/{projectId}/issues/search',
'post'
>;
interface IBody
extends Omit<
OAIRequestBody<'/api/admin/projects/{projectId}/issues/search', 'post'>,
'queries'
> {
queries: SearchQuery[];
}
const useIssueSearchInfinite = (
projectId: number,
body: IBody = { limit: 20, page: 1, queries: [], sort: {} },
) => {
return useInfiniteQuery<TData>({
queryKey: [
'/api/admin/projects/{projectId}/issues/search',
projectId,
body,
],
queryFn: async ({ pageParam }) => {
const { data: result } = await client.post({
path: '/api/admin/projects/{projectId}/issues/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 useIssueSearchInfinite;
@@ -0,0 +1,62 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type {
OAIMutationResponse,
OAIRequestBody,
SearchQuery,
} from '@/shared';
import { client } from '@/shared';
type TData = OAIMutationResponse<
'/api/admin/projects/{projectId}/issues/search',
'post'
>;
interface IBody
extends Omit<
OAIRequestBody<'/api/admin/projects/{projectId}/issues/search', 'post'>,
'queries'
> {
queries: SearchQuery[];
}
const useIssueSearch = (
projectId: number,
body: IBody = { limit: 10, page: 1, queries: [], sort: {} },
options?: Omit<UseQueryOptions<TData>, 'queryKey' | 'queryFn'>,
) => {
return useQuery<TData>({
queryKey: [
'/api/admin/projects/{projectId}/issues/search',
projectId,
body,
],
queryFn: async () => {
const { data: result } = await client.post({
path: '/api/admin/projects/{projectId}/issues/search',
pathParams: { projectId },
body,
});
return result;
},
...options,
});
};
export default useIssueSearch;
+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 IssueBadge } from './issue-badge.ui';
export { default as IssueSelectBox } from './issue-select-box.ui';
@@ -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 { useTranslation } from 'next-i18next';
import type { BadgeProps } from '@ufb/react';
import { Badge } from '@ufb/react';
import { ISSUES } from '@/shared';
import { BADGE_COLOR_MAP } from '@/shared/constants/color-map';
import type { BadgeColor } from '@/shared/constants/color-map';
import type { Issue, IssueStatus } from '../issue.type';
const ISSUE_COLOR_MAP: Record<IssueStatus, BadgeColor> = {
INIT: 'yellow',
ON_REVIEW: 'green',
IN_PROGRESS: 'sky',
RESOLVED: 'zinc',
PENDING: 'indigo',
};
interface IProps extends Omit<BadgeProps, 'color'> {
right?: React.ReactNode;
status: Issue['status'];
name?: string;
}
const IssueBadge: React.FC<IProps> = ({ name, status, right, ...props }) => {
const { t } = useTranslation();
return (
<Badge {...props} className={BADGE_COLOR_MAP[ISSUE_COLOR_MAP[status]]}>
{name ?? ISSUES(t).find((v) => v.key === status)?.name} {right}
</Badge>
);
};
export default IssueBadge;
@@ -0,0 +1,91 @@
/**
* 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 { useRouter } from 'next/router';
import { useThrottle } from 'react-use';
import { client } from '@/shared';
import AsyncMultiSelectSearchInput from '@/shared/ui/inputs/async-multi-select-search-input.ui';
import { useIssueSearchInfinite } from '../lib';
interface Props {
onChange: (value?: number[]) => void;
value?: number[];
}
const IssueSelectBox = ({ onChange, value }: Props) => {
const router = useRouter();
const projectId = Number(router.query.projectId as string);
const [selectedIssues, setSelectedIssues] = useState<
{ label: string; value: string }[]
>([]);
const [inputValue, setInputValue] = useState('');
const throttedValue = useThrottle(inputValue, 500);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useIssueSearchInfinite(projectId, {
queries: [{ key: 'name', value: throttedValue, condition: 'CONTAINS' }],
sort: { name: 'ASC' },
});
useEffect(() => {
if (!value) {
setSelectedIssues([]);
return;
}
void Promise.allSettled(
value.map(async (issueId) => {
try {
const { data } = await client.get({
path: '/api/admin/projects/{projectId}/issues/{issueId}',
pathParams: { projectId, issueId },
});
return { label: data.name, value: String(issueId) };
} catch (error) {
console.warn(`Failed to fetch issue ${issueId}:`, error);
return { label: `Issue ${issueId}`, value: String(issueId) };
}
}),
).then((results) => {
const successfulResults = results
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value);
setSelectedIssues(successfulResults);
});
}, [value]);
return (
<AsyncMultiSelectSearchInput
options={data.pages.flatMap((page) =>
page ?
page.items.map(({ name, id }) => ({ label: name, value: String(id) }))
: [],
)}
value={selectedIssues}
onChange={(input) => onChange(input.map((v) => Number(v.value)))}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
inputValue={inputValue}
setInputValue={setInputValue}
isFetchingNextPage={isFetchingNextPage}
/>
);
};
export default IssueSelectBox;

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