first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
@@ -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'];
|
||||
@@ -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.',
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user