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

This commit is contained in:
SDI committed 2026-07-15 18:05:12 +09:00
commit 12e4f17b62
4633 files changed
+817125

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { z } from 'zod';
export const aiSchema = z.object({
provider: z.enum(['OPEN_AI', 'GEMINI']),
apiKey: z.string().trim().min(1, { message: 'API Key is required' }),
endpointUrl: z.string().trim(),
systemPrompt: z.string().trim(),
tokenThreshold: z.number().nullable(),
notificationThreshold: z.number().nullable(),
});
export const aiTemplateSchema = z.object({
title: z.string().trim().min(1).max(30),
prompt: z.string().trim().min(1),
model: z.string(),
temperature: z.number(),
});
export const aiIssueSchema = z.object({
channelId: z.number().int(),
targetFieldKeys: z.array(z.string()).min(1),
prompt: z.string().trim(),
isEnabled: z.boolean(),
model: z.string(),
temperature: z.number(),
dataReferenceAmount: z.number(),
});
+22
View File
@@ -0,0 +1,22 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { z } from 'zod';
import type { aiIssueSchema, aiSchema, aiTemplateSchema } from './ai.schema';
export type AI = z.infer<typeof aiSchema>;
export type AITemplate = z.infer<typeof aiTemplateSchema>;
export type AIIssue = z.infer<typeof aiIssueSchema>;
+19
View File
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export * from './ui';
export * from './ai.type';
export * from './ai.schema';
export * from './lib';
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as useCheckAIUsageLimit } from './use-check-ai-usage-limit';
@@ -0,0 +1,115 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { useLocalStorage } from 'react-use';
import { Icon, toast } from '@ufb/react';
import { useOAIQuery } from '@/shared';
const useCheckAIUsageLimit = (projectId: number) => {
const { t } = useTranslation();
const { data: aiIntegrations } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/integrations',
variables: { projectId },
});
const { data: aiUsageLimit } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/usages',
variables: {
projectId,
from: dayjs().startOf('month').toISOString(),
to: dayjs().endOf('month').toISOString(),
},
});
const [checkedTotalLimitTime, setCheckedTotalLimitTime] =
useLocalStorage<string>(
`totalLimit-${projectId}-${dayjs().format('YYYY-MM')}`,
);
const [isCheckedNotificationLimit, setIsCheckedNotificationLimit] =
useLocalStorage(
`notificationLimit-${projectId}-${dayjs().format('YYYY-MM')}`,
false,
);
useEffect(() => {
if (!aiIntegrations?.apiKey) return;
if (aiUsageLimit) {
const totalUsage = aiUsageLimit.reduce(
(acc, usage) => acc + usage.usedTokens,
0,
);
const totalLimit = aiIntegrations.tokenThreshold;
if (!totalLimit) return;
if (totalUsage >= totalLimit) {
if (
!checkedTotalLimitTime ||
dayjs(checkedTotalLimitTime).add(1, 'day').isBefore(dayjs())
) {
setCheckedTotalLimitTime(dayjs().toISOString());
const toastId = toast.error(
t('v2.toast.ai-function-terminated.title'),
{
description: t('v2.toast.ai-function-terminated.description'),
cancel: {
label: <Icon name="RiCloseFill" size={20} />,
onClick: () => {
toast.dismiss(toastId);
},
},
duration: Infinity,
},
);
}
return;
}
const notificationLimit = aiIntegrations.notificationThreshold;
if (!notificationLimit) return;
if (totalUsage >= notificationLimit) {
if (!isCheckedNotificationLimit) {
const toastId = toast.warning(
t('v2.toast.ai-usage-limit-approaching.title'),
{
description: t(
'v2.toast.ai-usage-limit-approaching.description',
{
percentage: Math.round(
(notificationLimit / totalLimit) * 100,
),
},
),
cancel: {
label: <Icon name="RiCloseFill" size={20} />,
onClick: () => {
toast.dismiss(toastId);
},
},
duration: Infinity,
},
);
setIsCheckedNotificationLimit(true);
}
return;
}
}
}, [aiUsageLimit, aiIntegrations]);
};
export default useCheckAIUsageLimit;
@@ -0,0 +1,47 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { Icon } from '@ufb/react';
import { LoadingBars } from '@/features/update-ai-setting/ui/playground-output-card.ui';
interface Props {
value:
| { status: 'loading' | 'success' | 'error'; message: string }
| undefined;
isLoading?: boolean;
}
const AICell = memo((props: Props) => {
const { value, isLoading } = props;
if (!value) return null;
return (
<>
{value.status === 'loading' || isLoading ?
<LoadingBars width={410} />
: value.status === 'error' ?
<div className="text-tint-red">
<Icon name="RiErrorWarningFill" className="mr-2" size={16} />
<span>{value.message}</span>
</div>
: <p>{value.message}</p>}
</>
);
});
export default AICell;
@@ -0,0 +1,102 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { useFormContext } from 'react-hook-form';
import { FormField, RadioCard, RadioCardGroup } from '@ufb/react';
import { FormInput, FormTextarea } from '@/shared/ui/form-inputs';
import { GeminiIcon, OpenAIIcon } from '@/assets';
import type { AI } from '../ai.type';
const AiSettingForm = () => {
const { setValue, watch, control } = useFormContext<AI>();
const { t } = useTranslation();
const [previousValues, setPreviousValues] = useState<
Record<AI['provider'], { apiKey: string; endpointUrl: string }>
>({
GEMINI: { apiKey: '', endpointUrl: '' },
OPEN_AI: { apiKey: '', endpointUrl: '' },
});
return (
<div className="flex flex-col gap-4">
<RadioCardGroup
value={watch('provider')}
onValueChange={(currentProvider: 'OPEN_AI' | 'GEMINI') => {
const {
apiKey: previousApiKey,
provider: previousProvider,
endpointUrl: previousEndpointUrl,
} = watch();
setPreviousValues((prev) => ({
...prev,
[previousProvider]: {
apiKey: previousApiKey,
endpointUrl: previousEndpointUrl,
},
}));
setValue('provider', currentProvider, { shouldDirty: true });
setValue('apiKey', previousValues[currentProvider].apiKey);
setValue('endpointUrl', previousValues[currentProvider].endpointUrl);
}}
>
<RadioCard value="OPEN_AI" icon={<OpenAIIcon />} title="Open AI" />
<RadioCard value="GEMINI" icon={<GeminiIcon />} title="Gemini" />
</RadioCardGroup>
<FormField
control={control}
name="apiKey"
render={({ field }) => (
<FormInput
label="API Key"
placeholder={t('v2.placeholder.text')}
{...field}
required
/>
)}
/>
<FormField
control={control}
name="endpointUrl"
render={({ field }) => (
<FormInput
label="Endpoint URL"
placeholder={t('v2.placeholder.ai-endpoint-url')}
{...field}
/>
)}
/>
<FormField
control={control}
name="systemPrompt"
render={({ field }) => (
<FormTextarea
label="System Prompt"
placeholder={t('v2.placeholder.system-prompt')}
{...field}
/>
)}
/>
</div>
);
};
export default AiSettingForm;
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as AiSettingForm } from './ai-setting-form.ui';
export { default as AICell } from './ai-feedback-cell.ui';
@@ -0,0 +1,52 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { createColumnHelper } from '@tanstack/react-table';
import dayjs from 'dayjs';
import { Badge } from '@ufb/react';
import { CopyIconButton, DATE_TIME_FORMAT } from '@/shared';
import type { ApiKey } from './api-key.type';
const columnHelper = createColumnHelper<ApiKey>();
export const getApiKeyColumns = () => [
columnHelper.accessor('value', {
header: 'API KEY',
cell: ({ getValue }) => (
<div className="text-base-strong flex items-center">
{getValue()}
<CopyIconButton data={getValue()} />
</div>
),
enableSorting: false,
}),
columnHelper.accessor('deletedAt', {
header: 'Status',
cell: ({ getValue }) => (
<Badge color={getValue() === null ? 'green' : 'red'} radius="large">
{getValue() === null ? 'Active' : 'Inactive'}
</Badge>
),
enableSorting: false,
}),
columnHelper.accessor('createdAt', {
header: 'Created',
cell: ({ getValue }) => dayjs(getValue()).format(DATE_TIME_FORMAT),
}),
];
@@ -0,0 +1,28 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { z } from 'zod';
export const apiKeySchema = z.object({
id: z.number(),
value: z.string(),
createdAt: z.string(),
deletedAt: z.string().nullable(),
});
export const apiKeyFormSchema = z.object({
value: z.string(),
status: z.union([z.literal('active'), z.literal('inactive')]),
});
@@ -0,0 +1,23 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { z } from 'zod';
import type { apiKeyFormSchema, apiKeySchema } from './api-key.schema';
export type ApiKey = z.infer<typeof apiKeySchema>;
export type ApiKeyUpdateType = 'recover' | 'softDelete';
export type ApiKeyFormSchema = z.infer<typeof apiKeyFormSchema>;
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export * from './ui';
export * from './api-key.type';
@@ -0,0 +1,84 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'next-i18next';
import { useForm } from 'react-hook-form';
import type { FormOverlayProps } from '@/shared';
import { FormDialog, SelectInput, TextInput } from '@/shared';
import { apiKeyFormSchema } from '../api-key.schema';
import type { ApiKeyFormSchema } from '../api-key.type';
interface Props extends FormOverlayProps<ApiKeyFormSchema> {}
const ApiKeyFormDialog: React.FC<Props> = (props) => {
const {
data,
close,
isOpen,
onSubmit,
onClickDelete,
disabledDelete,
disabledUpdate,
} = props;
const { t } = useTranslation();
const { register, watch, setValue, handleSubmit, formState } =
useForm<ApiKeyFormSchema>({
resolver: zodResolver(apiKeyFormSchema),
defaultValues: data,
});
const { status } = watch();
return (
<FormDialog
isOpen={isOpen}
close={close}
title={t('v2.text.name.detail', { name: 'API Key' })}
submitBtn={{ form: 'apiKeyForm', disabled: disabledUpdate }}
deleteBtn={{
disabled: !!disabledDelete,
onClick: onClickDelete,
}}
formState={formState}
>
<form
id="apiKeyForm"
className="flex flex-col gap-4"
onSubmit={handleSubmit(onSubmit)}
>
<TextInput {...register('value')} label="API Key" disabled />
<SelectInput
label="Status"
options={[
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
]}
value={status}
onChange={(value) =>
setValue('status', value as 'active' | 'inactive', {
shouldDirty: true,
})
}
/>
</form>
</FormDialog>
);
};
export default ApiKeyFormDialog;
@@ -0,0 +1,94 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import {
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useOverlay } from '@toss/use-overlay';
import type { EntityTable } from '@/shared';
import { BasicTable } from '@/shared';
import { getApiKeyColumns } from '../api-key-columns';
import type { ApiKey, ApiKeyUpdateType } from '../api-key.type';
import ApiKeyFormDialog from './api-key-form-dialog.ui';
interface IProps extends Omit<EntityTable<ApiKey>, 'onClickRow'> {
onClickDelete?: (id: number) => Promise<void> | void;
onClickUpdate?: (type: ApiKeyUpdateType, id: number) => Promise<void> | void;
disabledUpdate?: boolean;
}
const ApiKeyTable: React.FC<IProps> = (props) => {
const {
isLoading,
onClickDelete,
onClickUpdate,
createButton,
data,
disabledUpdate,
disabledDelete,
} = props;
const overlay = useOverlay();
const columns = useMemo(() => getApiKeyColumns(), []);
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const openApiKeyDialog = (apiKey: ApiKey) => {
overlay.open(({ close, isOpen }) => (
<ApiKeyFormDialog
close={close}
isOpen={isOpen}
data={{
status: apiKey.deletedAt === null ? 'active' : 'inactive',
value: apiKey.value,
}}
onSubmit={async (input) => {
await onClickUpdate?.(
input.status === 'active' ? 'recover' : 'softDelete',
apiKey.id,
);
close();
}}
onClickDelete={async () => {
await onClickDelete?.(apiKey.id);
close();
}}
disabledDelete={disabledDelete}
disabledUpdate={disabledUpdate}
/>
));
};
return (
<BasicTable
isLoading={isLoading}
table={table}
createButton={createButton}
onClickRow={(_, row) => openApiKeyDialog(row)}
/>
);
};
export default ApiKeyTable;
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as ApiKeyTable } from './api-key-table.ui';
@@ -0,0 +1,22 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { z } from 'zod';
export const categorySchema = z.object({
id: z.number(),
name: z.string().trim(),
});
@@ -0,0 +1,20 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { z } from 'zod';
import type { categorySchema } from './category.schema';
export type Category = z.infer<typeof categorySchema>;
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export * from './category.type';
export * from './ui';
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as useCategorySearchInfinite } from './use-category-search-infinite';
@@ -0,0 +1,76 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useInfiniteQuery } from '@tanstack/react-query';
import type { OAIMutationResponse, OAIRequestBody } from '@/shared';
import { client } from '@/shared';
type TData = OAIMutationResponse<
'/api/admin/projects/{projectId}/categories/search',
'post'
>;
interface IBody
extends OAIRequestBody<
'/api/admin/projects/{projectId}/categories/search',
'post'
> {}
const useCategorySearchInfinite = (
projectId: number,
body: IBody = { limit: 10, page: 1, sort: {} },
) => {
return useInfiniteQuery<TData>({
queryKey: [
'/api/admin/projects/{projectId}/categories/search',
projectId,
body,
],
queryFn: async ({ pageParam }) => {
const { data: result } = await client.post({
path: '/api/admin/projects/{projectId}/categories/search',
pathParams: { projectId },
body: { ...body, page: pageParam as number },
});
return result;
},
getNextPageParam: (lastPage) => {
if (!lastPage) return undefined;
if (lastPage.meta.currentPage < lastPage.meta.totalPages) {
return lastPage.meta.currentPage + 1;
}
return undefined;
},
initialPageParam: 1,
initialData: {
pageParams: [],
pages: [
{
items: [],
meta: {
currentPage: 1,
totalPages: 0,
totalItems: 0,
itemCount: 0,
itemsPerPage: 0,
},
},
],
},
});
};
export default useCategorySearchInfinite;
@@ -0,0 +1,125 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import {
Badge,
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
Icon,
TextInput,
toast,
} from '@ufb/react';
import { client } from '@/shared';
import type { Category } from '@/entities/category';
interface Props {
projectId: number;
cateogry: Category;
}
const CategoryComboboxEditPopover = (props: Props) => {
const { projectId, cateogry } = props;
const { t } = useTranslation();
const queryClient = useQueryClient();
const refetch = async () => {
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/categories/search'],
});
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
});
};
const [inputValue, setInputValue] = useState(cateogry.name);
const { mutate: updateCategory } = useMutation({
mutationFn: async ({ name }: { name: string }) => {
const { data } = await client.put({
path: '/api/admin/projects/{projectId}/categories/{categoryId}',
pathParams: { projectId, categoryId: cateogry.id },
body: { name },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
const { mutate: deleteCategory } = useMutation({
mutationFn: async () => {
const { data } = await client.delete({
path: '/api/admin/projects/{projectId}/categories/{categoryId}',
pathParams: { projectId, categoryId: cateogry.id },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
return (
<Dropdown>
<DropdownTrigger asChild>
<Badge variant="subtle" onClick={(e) => e.stopPropagation()}>
Edit
</Badge>
</DropdownTrigger>
<DropdownContent onClick={(e) => e.stopPropagation()} side="right">
<div className="border-b">
<TextInput
className="border-none"
value={inputValue}
onChange={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
const name = inputValue.trim();
updateCategory({ name });
setInputValue(name);
}
}}
/>
</div>
<DropdownItem
className="gap-2"
onSelect={() => deleteCategory(undefined)}
>
<Icon name="RiDeleteBin5Fill" size={16} />
Delete Category
</DropdownItem>
</DropdownContent>
</Dropdown>
);
};
export default CategoryComboboxEditPopover;
@@ -0,0 +1,240 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import React, { useMemo, useState } from 'react';
import { useRouter } from 'next/router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { useThrottle } from 'react-use';
import {
Combobox,
ComboboxContent,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
toast,
} from '@ufb/react';
import {
client,
cn,
commandFilter,
InfiniteScrollArea,
usePermissions,
} from '@/shared';
import type { Category } from '@/entities/category';
import { useCategorySearchInfinite } from '../lib';
import CategoryComboboxEditPopover from './category-combobox-edit-popover.ui';
interface Props extends React.PropsWithChildren {
category?: Category | null;
issueId: number;
}
const CategoryCombobox = (props: Props) => {
const { category, issueId, children } = props;
const perms = usePermissions();
const router = useRouter();
const projectId = Number(router.query.projectId);
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const throttledvalue = useThrottle(inputValue, 500);
const queryClient = useQueryClient();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useCategorySearchInfinite(Number(projectId), {
limit: 10,
categoryName: throttledvalue,
sort: { name: 'ASC' },
});
const allcategories = useMemo(() => {
return data.pages
.map((v) => v?.items)
.filter((v) => !!v)
.flat();
}, [data]);
const refetch = async () => {
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/categories/search'],
});
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
});
};
const { mutate: attachCategory } = useMutation({
mutationFn: async ({ categoryId }: { categoryId: number }) => {
const { data } = await client.put({
path: '/api/admin/projects/{projectId}/issues/{issueId}/category/{categoryId}',
pathParams: { projectId, issueId, categoryId },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
const { mutate: detachCategory } = useMutation({
mutationFn: async ({ categoryId }: { categoryId: number }) => {
const { data } = await client.delete({
path: '/api/admin/projects/{projectId}/issues/{issueId}/category/{categoryId}',
pathParams: { projectId, issueId, categoryId },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
const { mutateAsync: createCategory } = useMutation({
mutationFn: async ({ name }: { name: string }) => {
const { data } = await client.post({
path: '/api/admin/projects/{projectId}/categories',
pathParams: { projectId },
body: { name },
});
return data;
},
async onSuccess() {
await refetch();
setInputValue('');
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
return (
<Combobox>
<ComboboxTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
disabled={!perms.includes('issue_update')}
className={cn(
{ 'opacity-50': !perms.includes('issue_update') },
'w-fit',
)}
>
{children}
</button>
</ComboboxTrigger>
<ComboboxContent options={{ filter: commandFilter }}>
<ComboboxInput
onClick={(e) => e.stopPropagation()}
onValueChange={(value) => setInputValue(value)}
value={inputValue}
/>
<ComboboxList onClick={(e) => e.stopPropagation()} maxHeight="200px">
<ComboboxGroup
heading={
<span className="text-neutral-tertiary text-base-normal">
Selected Category
</span>
}
>
{allcategories
.filter((v) => category?.id === v.id)
.map(({ id, name }) => (
<ComboboxItem
key={id}
value={name}
onSelect={() => {
if (category?.id === id) detachCategory({ categoryId: id });
if (category?.id !== id) attachCategory({ categoryId: id });
}}
>
<span className="flex-1">{name}</span>
<span className="text-neutral-tertiary text-small-normal">
Remove
</span>
</ComboboxItem>
))}
</ComboboxGroup>
<ComboboxGroup
heading={
<span className="text-neutral-tertiary text-base-normal">
Category List
</span>
}
>
{allcategories
.filter((v) => category?.id !== v.id)
.map(({ id, name }) => (
<ComboboxItem
key={id}
value={name}
onSelect={() => {
if (category?.id === id) detachCategory({ categoryId: id });
if (category?.id !== id) attachCategory({ categoryId: id });
}}
>
<span className="flex-1">{name}</span>
<CategoryComboboxEditPopover
projectId={projectId}
cateogry={{ id, name }}
/>
</ComboboxItem>
))}
<InfiniteScrollArea
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
</ComboboxGroup>
{!!inputValue &&
perms.includes('issue_update') &&
!allcategories.some((v) => v.name === inputValue) && (
<div
className="combobox-item"
onClick={async () => {
const name = inputValue.trim();
const data = await createCategory({ name });
if (!data) return;
setInputValue(name);
attachCategory({ categoryId: data.id });
}}
>
<span className="flex-1">{inputValue}</span>
<span className="text-neutral-tertiary text-small-normal">
Create
</span>
</div>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};
export default CategoryCombobox;
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as CategoryCombobox } from './category-combobox.ui';
@@ -0,0 +1,49 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { i18n } from 'next-i18next';
import { z } from 'zod';
export const channelImageConfigSchema = z.object({
accessKeyId: z.string().trim(),
secretAccessKey: z.string().trim(),
endpoint: z.string().trim(),
region: z.string().trim(),
bucket: z.string().trim(),
domainWhiteList: z.array(z.string().trim()).nullable(),
enablePresignedUrlDownload: z.boolean().optional(),
});
export const channelSchema = z.object({
id: z.number(),
name: z
.string()
.trim()
.min(1, { message: i18n?.t('hint.required') })
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
description: z
.string()
.trim()
.max(50, { message: i18n?.t('hint.max-length', { length: 50 }) })
.nullable(),
createdAt: z.string(),
updatedAt: z.string(),
imageConfig: channelImageConfigSchema.nullable().optional(),
feedbackSearchMaxDays: z.number(),
});
export const channelInfoSchema = channelSchema
.pick({ name: true, description: true, feedbackSearchMaxDays: true })
.merge(z.object({ id: z.number().optional() }));
@@ -0,0 +1,27 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { z } from 'zod';
import type {
channelImageConfigSchema,
channelInfoSchema,
channelSchema,
} from './channel.schema';
export type Channel = z.infer<typeof channelSchema>;
export type ChannelInfo = z.infer<typeof channelInfoSchema>;
export type ChannelImageConfig = z.infer<typeof channelImageConfigSchema>;
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export * from './channel.type';
export * from './channel.schema';
export * from './ui';
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as useRoutingChannelCreation } from './use-routing-channel-creation';
@@ -0,0 +1,57 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useRouter } from 'next/router';
import { useOverlay } from '@toss/use-overlay';
import { CreatingDialog, Path } from '@/shared';
import { useCreateChannelStore } from '@/features/create-channel';
const useRoutingChannelCreation = (projectId: number) => {
const { editingStepIndex, reset, jumpStepByIndex } = useCreateChannelStore();
const overlay = useOverlay();
const router = useRouter();
const openChannelInProgress = async () => {
if (editingStepIndex !== null) {
await new Promise<boolean>((resolve) =>
overlay.open(({ close, isOpen }) => (
<CreatingDialog
isOpen={isOpen}
close={close}
type="Channel"
onRestart={() => {
reset();
resolve(true);
}}
onContinue={() => {
jumpStepByIndex(editingStepIndex);
resolve(true);
}}
/>
)),
);
}
await router.push({ pathname: Path.CREATE_CHANNEL, query: { projectId } });
};
const isCreatingChannel = editingStepIndex !== null;
return {
openChannelInProgress,
isCreatingChannel,
};
};
export default useRoutingChannelCreation;
@@ -0,0 +1,83 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useTranslation } from 'next-i18next';
import { useFormContext } from 'react-hook-form';
import { SelectInput, TextInput } from '@/shared';
import type { ChannelInfo } from '../channel.type';
interface IProps {
type?: 'create' | 'update';
readOnly?: boolean;
}
const ChannelInfoForm: React.FC<IProps> = (props) => {
const { type = 'create', readOnly = false } = props;
const { t } = useTranslation();
const { register, formState, setValue, watch } =
useFormContext<ChannelInfo>();
const feedbackSearchMaxDays = watch('feedbackSearchMaxDays');
return (
<div className="flex flex-col gap-4">
{type === 'update' && (
<TextInput label="ID" {...register('id')} disabled />
)}
<TextInput
label="Name"
{...register('name')}
placeholder={t('v2.placeholder.text')}
required
disabled={readOnly}
error={formState.errors.name?.message}
/>
<TextInput
label="Description"
{...register('description')}
placeholder={t('v2.placeholder.text')}
disabled={readOnly}
error={formState.errors.description?.message}
/>
<SelectInput
label="Maximum feedback search period"
value={
feedbackSearchMaxDays ? String(feedbackSearchMaxDays) : undefined
}
onChange={(value) => {
if (!value) return;
setValue('feedbackSearchMaxDays', Number(value), {
shouldDirty: true,
});
}}
options={[
{ value: '30', label: t('text.date.before-days', { day: 30 }) },
{ value: '90', label: t('text.date.before-days', { day: 90 }) },
{ value: '180', label: t('text.date.before-days', { day: 180 }) },
{ value: '365', label: t('text.date.before-days', { day: 365 }) },
{ value: '-1', label: t('text.date.entire-period') },
]}
disabled={readOnly}
error={formState.errors.feedbackSearchMaxDays?.message}
/>
</div>
);
};
export default ChannelInfoForm;
@@ -0,0 +1,259 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { useFormContext } from 'react-hook-form';
import { Badge, Button, Divider, Icon, toast } from '@ufb/react';
import {
ComboboxInputbox,
SelectInput,
TextInput,
useOAIMutation,
} from '@/shared';
import type { ChannelImageConfig } from '../channel.type';
interface IProps {
readOnly?: boolean;
}
const ImageConfigForm: React.FC<IProps> = (props) => {
const { readOnly = false } = props;
const { t } = useTranslation();
const [inputDomain, setInputDomain] = useState('');
const [connectionError, setConnectionError] = useState(false);
const {
register,
formState,
watch,
setValue,
setError,
clearErrors,
getValues,
} = useFormContext<ChannelImageConfig>();
const domainWhiteList = watch('domainWhiteList');
const addDomainWhiteList = (value: string) => {
if (domainWhiteList?.includes(value)) {
setError('domainWhiteList', {
message: t('hint.name-already-exists', { name: 'Domain' }),
});
return;
}
if (!/[a-z]+\.[a-z]{2,3}/.test(value)) {
setError('domainWhiteList', { message: t('hint.invalid-domain') });
return;
}
setValue('domainWhiteList', (domainWhiteList ?? []).concat(value), {
shouldDirty: true,
});
clearErrors('domainWhiteList');
setInputDomain('');
};
const removeDomainWhiteList = (index: number) => {
if (!domainWhiteList) return;
setValue(
'domainWhiteList',
domainWhiteList.filter((_, i) => i !== index),
{ shouldDirty: true },
);
};
const { mutate: testConection } = useOAIMutation({
method: 'post',
path: '/api/admin/projects/{projectId}/channels/image-upload-url-test',
pathParams: { projectId: 0 },
queryOptions: {
onSuccess(data) {
if (data?.success) {
toast.success(t('v2.toast.success'));
} else {
setError('accessKeyId', { message: '' });
setError('bucket', { message: '' });
setError('endpoint', { message: '' });
setError('region', { message: '' });
setError('root', { message: '' });
setError('secretAccessKey', { message: '' });
toast.error('Test Connection failed');
setConnectionError(false);
setConnectionError(true);
}
},
onError() {
toast.error('Test Connection failed');
setConnectionError(true);
},
},
});
const handleTestConnection = () => {
let isError = false;
const { accessKeyId, bucket, endpoint, region, secretAccessKey } =
getValues();
if (accessKeyId.length === 0) {
setError('accessKeyId', { message: t('hint.required') });
isError = true;
}
if (bucket.length === 0) {
setError('bucket', { message: t('hint.required') });
isError = true;
}
if (endpoint.length === 0) {
setError('endpoint', { message: t('hint.required') });
isError = true;
}
if (region.length === 0) {
setError('region', { message: t('hint.required') });
isError = true;
}
if (secretAccessKey.length === 0) {
setError('secretAccessKey', { message: t('hint.required') });
isError = true;
}
if (isError) {
setConnectionError(true);
return;
}
testConection(getValues());
};
return (
<div>
<div className="border-neutral-tertiary mb-4 flex flex-col gap-6 rounded border p-4">
<h5 className="text-title-h5">
{t('title-box.image-storage-integration')}
</h5>
<div className="flex flex-col gap-4">
<TextInput
label="Access Key ID"
placeholder={t('v2.placeholder.text')}
{...register('accessKeyId')}
error={formState.errors.accessKeyId?.message}
disabled={readOnly}
/>
<TextInput
{...register('secretAccessKey')}
label="Secret Access Key"
placeholder={t('v2.placeholder.text')}
error={formState.errors.secretAccessKey?.message}
disabled={readOnly}
/>
<TextInput
{...register('endpoint')}
label="End Point"
placeholder={t('v2.placeholder.text')}
error={formState.errors.endpoint?.message}
disabled={readOnly}
/>
<TextInput
{...register('region')}
label="Region"
placeholder={t('v2.placeholder.text')}
error={formState.errors.region?.message}
disabled={readOnly}
/>
<TextInput
{...register('bucket')}
label="Bucket Name"
placeholder={t('v2.placeholder.text')}
error={formState.errors.bucket?.message}
disabled={readOnly}
/>
<SelectInput
options={[
{ label: 'Enable', value: 'true' },
{ label: 'Disable', value: 'false' },
]}
value={
typeof watch('enablePresignedUrlDownload') !== 'undefined' ?
String(watch('enablePresignedUrlDownload'))
: ''
}
placeholder={t('v2.placeholder.select')}
onChange={(v) =>
setValue('enablePresignedUrlDownload', v === 'true', {
shouldDirty: true,
})
}
label="Presigned URL Download"
/>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
className="btn btn-secondary"
onClick={handleTestConnection}
>
Test Connection
</Button>
{connectionError && (
<div className="text-tint-red flex items-center gap-1">
<Icon name="RiErrorWarningFill" size={20} />
<span>{t('v2.text.test-connection-failed')}</span>
</div>
)}
</div>
</div>
<div className="border-neutral-tertiary flex flex-col gap-2 rounded border p-4">
<h5 className="text-title-h5">Image URL Domain Whitelist</h5>
<div className="flex gap-4">
<ComboboxInputbox
clearError={() => clearErrors('domainWhiteList')}
inputValue={inputDomain}
setInputValue={setInputDomain}
onSelectValue={addDomainWhiteList}
error={formState.errors.domainWhiteList?.message}
placeholder="example.com"
>
Whitelist
</ComboboxInputbox>
<Divider orientation="vertical" className="h-5" variant="subtle" />
<div className="flex items-center gap-2">
{!domainWhiteList || domainWhiteList.length === 0 ?
<p className="text-neutral-tertiary">
{t('v2.text.all-image-url-allow')}
</p>
: domainWhiteList.map((domain, index) => (
<Badge
key={index}
className="flex items-center"
variant="subtle"
>
{domain}
<Icon
name="RiCloseLine"
onClick={() => removeDomainWhiteList(index)}
size={16}
/>
</Badge>
))
}
</div>
</div>
</div>
</div>
);
};
export default ImageConfigForm;
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as ChannelInfoForm } from './channel-info-form.ui';
export { default as ImageConfigForm } from './image-config-form.ui';
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export * from './ui';
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as useLineChartData } from './use-line-chart-data';
@@ -0,0 +1,96 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import dayjs from 'dayjs';
import minMax from 'dayjs/plugin/minMax';
import { CHART_FIVE_COLORS, getDayCount } from '@/shared';
dayjs.extend(minMax);
const getDarkColor = () => {
return (
'#' +
Array.from({ length: 6 })
.map(() => Math.floor(Math.random() * 16).toString(16))
.join('')
);
};
const useLineChartData = (
from: Date,
to: Date,
targetData: { id: number; name: string }[],
data?: {
id: number;
statistics: { startDate: string; endDate: string; count: number }[];
}[],
) => {
const dataKeys = useMemo(
() =>
targetData
.sort((a, b) => a.id - b.id)
.map(({ name }, i) => ({
color: CHART_FIVE_COLORS[i] ?? getDarkColor(),
name,
})),
[targetData],
);
const chartData = useMemo(() => {
if (!data) return [];
const result = [];
let currentDate = dayjs(to).endOf('day');
const startDate = dayjs(from).startOf('day');
const dayCount = getDayCount(from, to);
while (currentDate.isAfter(startDate)) {
const prevDate = dayjs.max(
currentDate.subtract(dayCount > 50 ? 6 : 0, 'day'),
dayjs(from),
);
const channelData = targetData.reduce(
(acc, cur) => {
const currentData = data.find((v) => v.id === cur.id);
const count =
currentData?.statistics.find(
(v) => v.endDate === currentDate.format('YYYY-MM-DD'),
)?.count ?? 0;
return { ...acc, [cur.name]: count };
},
{
date:
dayCount > 50 ?
`${prevDate.format('MM/DD')} - ${currentDate.format('MM/DD')}`
: currentDate.format('MM/DD'),
},
);
result.push(channelData);
currentDate = prevDate.subtract(1, 'day');
}
return result.reverse();
}, [data, targetData]);
return { chartData, dataKeys };
};
export default useLineChartData;
@@ -0,0 +1,56 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const CreateFeedbackPerIssueCard: React.FC<IProps> = (props) => {
const { from, to, projectId } = props;
const { t } = useTranslation();
const { data } = useOAIQuery({
path: '/api/admin/statistics/feedback/issued-ratio',
variables: { from: from.toISOString(), to: to.toISOString(), projectId },
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
return (
<DashboardCard
data={`${parseFloat(Math.abs((data?.ratio ?? 0) * 100).toFixed(1))}%`}
title={t('dashboard-card.issue-ratio.title')}
description={`${t('dashboard-card.issue-ratio.description', {
targetDate: `${dayjs(from).format('YYYY/MM/DD')} - ${dayjs(to).format(
'YYYY/MM/DD',
)}`,
})}`}
type="issue"
/>
);
};
export default CreateFeedbackPerIssueCard;
@@ -0,0 +1,144 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import {
Combobox,
ComboboxContent,
ComboboxList,
ComboboxSelectItem,
ComboboxTrigger,
Icon,
} from '@ufb/react';
import {
ChartCard,
Legend,
LineChart,
useAllChannels,
useOAIQuery,
} from '@/shared';
import type { Channel } from '@/entities/channel';
import { useLineChartData } from '../lib';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const FeedbackLineChartWrapper: React.FC<IProps> = (props) => {
const { from, projectId, to } = props;
const { data: channels } = useAllChannels(projectId);
const { t } = useTranslation();
const [currentChannels, setCurrentChannels] = useState<Channel[]>([]);
const dayCount = useMemo(() => dayjs(to).diff(from, 'day') + 1, [from, to]);
useEffect(() => {
setCurrentChannels(channels?.items.slice(0, 5) ?? []);
}, [channels]);
const { data } = useOAIQuery({
path: '/api/admin/statistics/feedback',
variables: {
startDate: dayjs(from).startOf('day').format('YYYY-MM-DD'),
endDate: dayjs(to).endOf('day').format('YYYY-MM-DD'),
channelIds: currentChannels.map(({ id }) => id).join(','),
interval: dayCount > 50 ? 'week' : 'day',
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { chartData, dataKeys } = useLineChartData(
from,
to,
currentChannels,
data?.channels ?? [],
);
return (
<ChartCard
title={t('chart.feedback-trend.title')}
description={`${t('chart.feedback-trend.description')} (${dayjs(
from,
).format('YYYY/MM/DD')} - ${dayjs(to).format('YYYY/MM/DD')})`}
extra={
<div className="flex gap-3">
<Legend dataKeys={dataKeys} />
<ChannelSelectCombobox
currentChannels={currentChannels}
setCurrentChannels={setCurrentChannels}
channels={channels}
/>
</div>
}
>
<LineChart height={400} dataKeys={dataKeys} data={chartData} />
</ChartCard>
);
};
interface ChannelSelectComboboxProps {
channels?: { items: Channel[] };
currentChannels: Channel[];
setCurrentChannels: React.Dispatch<React.SetStateAction<Channel[]>>;
}
const ChannelSelectCombobox = (props: ChannelSelectComboboxProps) => {
const { channels, currentChannels, setCurrentChannels } = props;
return (
<Combobox>
<ComboboxTrigger>
<Icon name="RiFilter3Line" />
Filter
</ComboboxTrigger>
<ComboboxContent>
<ComboboxList maxHeight="200px">
{channels?.items.map((channel) => (
<ComboboxSelectItem
key={channel.id}
value={String(channel.id)}
checked={currentChannels.some(({ id }) => id === channel.id)}
onSelect={() => {
const isChecked = currentChannels.some(
({ id }) => id === channel.id,
);
setCurrentChannels((prev) =>
isChecked ? prev.filter(({ id }) => id !== channel.id)
: prev.length === 5 ? [...prev.slice(1), channel]
: [...prev, channel],
);
}}
>
{channel.name}
</ComboboxSelectItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};
export default FeedbackLineChartWrapper;
@@ -0,0 +1,38 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as IssueRank } from './issue-rank.ui';
export { default as TotalFeedbackCard } from './total-feedback-card.ui';
export { default as TotalIssueCard } from './total-issue-card.ui';
export { default as CreateFeedbackPerIssueCard } from './create-feedback-per-issue-card.ui';
export { default as TodayFeedbackCard } from './today-feedback-card.ui';
export { default as YesterdayFeedbackCard } from './yesterday-feedback-card.ui';
export { default as SevenDaysFeedbackCard } from './seven-days-feedback-card.ui';
export { default as ThirtyDaysFeedbackCard } from './thirty-days-feedback-card.ui';
export { default as TodayIssueCard } from './today-issue-card.ui';
export { default as YesterdayIssueCard } from './yesterday-issue-card.ui';
export { default as SevenDaysIssueCard } from './seven-days-issue-card.ui';
export { default as ThirtyDaysIssueCard } from './thirty-days-issue-card.ui';
export { default as IssueLineChart } from './issue-line-chart.ui';
export { default as IssueBarChart } from './issue-bar-chart.ui';
export { default as IssueFeedbackLineChart } from './issue-feedback-line-chart.ui';
export { default as FeedbackLineChart } from './feedback-line-chart.ui';
@@ -0,0 +1,147 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { ToggleGroup, ToggleGroupItem } from '@ufb/react';
import {
BarChart,
ChartCard,
ISSUES,
Path,
PieChart,
useOAIQuery,
} from '@/shared';
import type { IssueStatus } from '@/entities/issue';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const COLOR_MAP: Record<IssueStatus, string> = {
INIT: '#EAB308',
IN_PROGRESS: '#0EA5E9',
ON_REVIEW: '#22C55E',
PENDING: '#6366F1',
RESOLVED: '#71717A',
};
const IssueBarChart: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const [type, setType] = useState('bar');
const { data: statisticsData } = useOAIQuery({
path: '/api/admin/statistics/issue/count-by-status',
variables: { projectId },
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const onChangeType = (type: 'bar' | 'pie') => (v: string) => {
setType(v === '' ? type : v);
};
const data = ISSUES(t).map(({ key, name }) => ({
name,
value: +(
statisticsData?.statistics.find((v) => v.status === key)?.count ?? 0
),
color: COLOR_MAP[key],
}));
const onClickIssue = (name: string | undefined) => {
if (!name) return;
const issue = ISSUES(t).find((v) => v.name === name);
window.open(
Path.ISSUE.replace('[projectId]', projectId.toString()) +
'?queries=' +
JSON.stringify([{ key: 'status', value: issue?.key, condition: 'IS' }]),
'_blank',
);
};
return (
<ChartCard
title={t('chart.issue-status-count.title')}
description={t('chart.issue-status-count.description')}
extra={
<ToggleGroup
type="single"
value={type}
onValueChange={onChangeType('pie')}
>
<ToggleGroupItem value="bar">Bar</ToggleGroupItem>
<ToggleGroupItem value="pie">Pie</ToggleGroupItem>
</ToggleGroup>
}
>
{type === 'pie' && (
<div className="flex items-center">
<PieChart data={data} onClick={onClickIssue} height={415} />
<IssueLegend data={data} />
</div>
)}
{type === 'bar' && (
<BarChart data={data} height={415} onClick={onClickIssue} />
)}
</ChartCard>
);
};
interface IssueLegendProps {
data?: { name: string; value: number; color: string }[];
}
const IssueLegend: React.FC<IssueLegendProps> = ({ data }) => {
return (
<div className="w-full">
<table className="table">
<thead>
<tr>
<th className="table-head !text-large-strong text-neutral-primary h-auto">
Status
</th>
<th className="table-head !text-large-strong text-neutral-primary h-auto">
Issue Count
</th>
</tr>
</thead>
<tbody>
{data?.map((item, index) => (
<tr key={`item-${index}`}>
<td className="!text-large-normal text-neutral-primary table-cell py-2">
<div className="flex items-center gap-2">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span>{item.name}</span>
</div>
</td>
<td className="!text-large-normal text-neutral-primary table-cell py-2">
{item.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default IssueBarChart;
@@ -0,0 +1,211 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { useThrottle } from 'react-use';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
Icon,
toast,
} from '@ufb/react';
import {
ChartCard,
client,
commandFilter,
getDayCount,
InfiniteScrollArea,
Legend,
LineChart,
useOAIQuery,
} from '@/shared';
import type { Issue } from '@/entities/issue';
import { IssueBadge, useIssueSearchInfinite } from '@/entities/issue';
import { useLineChartData } from '../lib';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const IssueFeedbackLineChart: React.FC<IProps> = ({ from, projectId, to }) => {
const { t } = useTranslation();
const [searchName, setSearchName] = useState('');
const throttledSearchName = useThrottle(searchName, 1000);
const [currentIssues, setCurrentIssues] = useState<Issue[]>([]);
const { data } = useOAIQuery({
path: '/api/admin/statistics/feedback-issue',
variables: {
startDate: dayjs(from).startOf('day').format('YYYY-MM-DD'),
endDate: dayjs(to).endOf('day').format('YYYY-MM-DD'),
issueIds: currentIssues.map((issue) => issue.id).join(','),
interval: getDayCount(from, to) > 50 ? 'week' : 'day',
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
useEffect(() => {
void client
.post({
path: '/api/admin/projects/{projectId}/issues/search',
body: { sort: { feedbackCount: 'DESC' }, limit: 5 },
pathParams: { projectId },
})
.then(({ data }) => setCurrentIssues(data?.items ?? []));
}, [projectId]);
const { chartData, dataKeys } = useLineChartData(
from,
to,
currentIssues,
data?.issues.map((v) => ({
id: v.id,
statistics: v.statistics.map(({ startDate, endDate, feedbackCount }) => ({
startDate,
endDate,
count: feedbackCount,
})),
})),
);
const {
data: allIssueData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useIssueSearchInfinite(Number(projectId), {
limit: 10,
queries: [
{ key: 'name', value: throttledSearchName, condition: 'CONTAINS' },
],
sort: { name: 'ASC' },
});
const allIssues = useMemo(() => {
return allIssueData.pages
.map((v) => v?.items)
.filter((v) => !!v)
.flat();
}, [allIssueData]);
const handleIssueCheck = (issue: Issue) => {
if (currentIssues.length === 5) {
toast.error(t('popover.select-issue.max-issue-count'));
return;
}
setCurrentIssues((prev) => [...prev, issue]);
setSearchName('');
};
const handleIssueUncheck = (issue: Issue) => {
setCurrentIssues((prev) => prev.filter((v) => v.id !== issue.id));
};
return (
<ChartCard
title={t('chart.issue-comparison.title')}
description={`${t('chart.issue-comparison.description')} (${dayjs(
from,
).format('YYYY/MM/DD')} - ${dayjs(to).format('YYYY/MM/DD')})`}
extra={
<div className="flex gap-3">
<Legend dataKeys={dataKeys} />
<Combobox>
<ComboboxTrigger>
<Icon name="RiFilter3Line" />
Filter
</ComboboxTrigger>
<ComboboxContent options={{ filter: commandFilter }}>
<ComboboxInput
onValueChange={(value) => setSearchName(value)}
value={searchName}
/>
<ComboboxList maxHeight="200px">
<ComboboxEmpty>No results found.</ComboboxEmpty>
<ComboboxGroup
heading={
<span className="text-neutral-tertiary text-base-normal">
Selected Issue
</span>
}
>
{currentIssues.map((issue) => (
<ComboboxItem
key={issue.id}
onSelect={() => handleIssueUncheck(issue)}
>
<IssueBadge name={issue.name} status={issue.status} />
</ComboboxItem>
))}
</ComboboxGroup>
{allIssues.length > 0 && (
<ComboboxGroup
heading={
<span className="text-neutral-tertiary text-base-normal">
Issue List
</span>
}
>
{allIssues
.filter(
(v) =>
!currentIssues.some((issue) => issue.id === v.id),
)
.map((issue) => (
<ComboboxItem
key={issue.id}
value={issue.name}
onSelect={() => handleIssueCheck(issue)}
>
<IssueBadge name={issue.name} status={issue.status} />
</ComboboxItem>
))}
<InfiniteScrollArea
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
}
>
<LineChart height={400} dataKeys={dataKeys} data={chartData} />
</ChartCard>
);
};
export default IssueFeedbackLineChart;
@@ -0,0 +1,67 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { ChartCard, getDayCount, LineChart, useOAIQuery } from '@/shared';
import { useLineChartData } from '../lib';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const IssueLineChart: React.FC<IProps> = ({ from, projectId, to }) => {
const { t } = useTranslation();
const { data } = useOAIQuery({
path: '/api/admin/statistics/issue/count-by-date',
variables: {
projectId,
startDate: dayjs(from).startOf('day').format('YYYY-MM-DD'),
endDate: dayjs(to).endOf('day').format('YYYY-MM-DD'),
interval: getDayCount(from, to) > 50 ? 'week' : 'day',
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { chartData, dataKeys } = useLineChartData(
from,
to,
[{ id: 1, name: '' }],
[{ id: 1, statistics: data?.statistics ?? [] }],
);
return (
<ChartCard
title={t('chart.issue-trend.title')}
description={`${t('chart.issue-trend.description')} (${dayjs(from).format(
'YYYY/MM/DD',
)} - ${dayjs(to).format('YYYY/MM/DD')})`}
>
<LineChart height={400} data={chartData} dataKeys={dataKeys} />
</ChartCard>
);
};
export default IssueLineChart;
@@ -0,0 +1,197 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
createColumnHelper,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useTranslation } from 'next-i18next';
import {
Combobox,
ComboboxContent,
ComboboxList,
ComboboxSelectItem,
ComboboxTrigger,
Icon,
} from '@ufb/react';
import { ISSUES, Path } from '@/shared';
import { DashboardTable } from '@/shared/ui';
import type { IssueStatus } from '@/entities/issue';
import { IssueBadge, useIssueSearch } from '@/entities/issue';
interface IssueTableData {
id: number;
no: number;
status: IssueStatus;
name: string;
count: number;
}
const columnHelper = createColumnHelper<IssueTableData>();
const columns = [
columnHelper.accessor('no', {
header: 'No',
enableSorting: false,
size: 30,
}),
columnHelper.accessor('name', {
header: 'Issue',
enableSorting: false,
cell({ getValue, row }) {
const router = useRouter();
return (
<>
<span>{getValue()}</span>
<Link
href={{
pathname: Path.ISSUE,
query: {
projectId: router.query.projectId,
queries: JSON.stringify([
{ key: 'name', value: row.original.name, condition: 'IS' },
]),
},
}}
target="_blank"
rel="noreferrer"
className="ml-1"
>
<Icon
name="RiExternalLinkFill"
size={16}
className="cursor-pointer"
/>
</Link>
</>
);
},
}),
columnHelper.accessor('status', {
header: 'Status',
enableSorting: false,
cell({ getValue }) {
return <IssueBadge status={getValue()} />;
},
}),
columnHelper.accessor('count', {
header: 'Feedback Count',
cell: ({ getValue }) => <p>{getValue().toLocaleString()}</p>,
enableSorting: true,
}),
];
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const limitOptions = [
{ label: 'Top 5', value: '5' },
{ label: 'Top 10', value: '10' },
{ label: 'Top 15', value: '15' },
{ label: 'Top 20', value: '20' },
];
const IssueRank: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const issues = useMemo(() => ISSUES(t), [t]);
const [limit, setLimit] = useState(Number(limitOptions[0]?.value ?? 0));
const [currentIssueStatusList, setCurrentIssueStatusList] = useState(issues);
const { data } = useIssueSearch(projectId, {
sort: { feedbackCount: 'DESC' },
limit,
queries: currentIssueStatusList.map((v) => ({
key: 'status',
value: v.key,
condition: 'IS',
})),
operator: 'OR',
});
const newData = useMemo(
() =>
data?.items.map((item, i) => ({
id: item.id,
no: i + 1,
count: item.feedbackCount,
name: item.name,
status: item.status,
})) ?? [],
[data, t],
);
const table = useReactTable({
columns,
data: newData,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<DashboardTable
title={t('chart.issue-rank.title')}
description={t('chart.issue-rank.description')}
table={table}
selectData={{
options: limitOptions,
value: String(limit),
onChange: (v) => setLimit(Number(v)),
}}
filterContent={
<Combobox>
<ComboboxTrigger>
<Icon name="RiFilter3Line" />
Filter
</ComboboxTrigger>
<ComboboxContent>
<ComboboxList>
{issues.map((issue) => (
<ComboboxSelectItem
key={issue.key}
checked={currentIssueStatusList.some(
({ key }) => key === issue.key,
)}
onSelect={() => {
const isChecked = currentIssueStatusList.some(
({ key }) => key === issue.key,
);
setCurrentIssueStatusList((prev) =>
isChecked ?
prev.filter((v) => v.key !== issue.key)
: [...prev, issue],
);
}}
>
{issue.name}
</ComboboxSelectItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
}
/>
);
};
export default IssueRank;
@@ -0,0 +1,88 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const SevenDaysFeedbackCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(7, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(14, 'day').startOf('day').toISOString(),
to: dayjs().subtract(8, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="feedback"
data={currentData?.count ?? 0}
title={t('dashboard-card.n-days-feedback.title', { n: 7 })}
description={t('dashboard-card.n-days-feedback.description', {
n: 7,
targetDate: `${dayjs()
.subtract(7, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(1, 'day')
.format('YYYY/MM/DD')}`,
compareDate: `${dayjs()
.subtract(14, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(8, 'day')
.format('YYYY/MM/DD')}`,
})}
percentage={percentage}
/>
);
};
export default SevenDaysFeedbackCard;
@@ -0,0 +1,87 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const SevenDaysIssueCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(7, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(14, 'day').startOf('day').toISOString(),
to: dayjs().subtract(8, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="issue"
data={currentData?.count ?? 0}
title={t('dashboard-card.n-days-issue.title', { n: 7 })}
description={t('dashboard-card.n-days-issue.description', {
n: 7,
targetDate: `${dayjs()
.subtract(7, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(1, 'day')
.format('YYYY/MM/DD')}`,
compareDate: `${dayjs()
.subtract(14, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(8, 'day')
.format('YYYY/MM/DD')}`,
})}
percentage={percentage}
/>
);
};
export default SevenDaysIssueCard;
@@ -0,0 +1,88 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const ThirtyDaysFeedbackCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(30, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(60, 'day').startOf('day').toISOString(),
to: dayjs().subtract(31, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="feedback"
data={currentData?.count ?? 0}
title={t('dashboard-card.n-days-feedback.title', { n: 30 })}
description={t('dashboard-card.n-days-feedback.description', {
n: 30,
targetDate: `${dayjs()
.subtract(30, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(1, 'day')
.format('YYYY/MM/DD')}`,
compareDate: `${dayjs()
.subtract(60, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(31, 'day')
.format('YYYY/MM/DD')}`,
})}
percentage={percentage}
/>
);
};
export default ThirtyDaysFeedbackCard;
@@ -0,0 +1,90 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const ThirtyDaysIssueCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(30, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(60, 'day').startOf('day').toISOString(),
to: dayjs().subtract(31, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="issue"
data={currentData?.count ?? 0}
title={t('dashboard-card.n-days-issue.title', { n: 30 })}
description={t('dashboard-card.n-days-issue.description', {
n: 30,
targetDate: `${dayjs()
.subtract(30, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(1, 'day')
.format('YYYY/MM/DD')}`,
compareDate: `${dayjs()
.subtract(60, 'day')
.format('YYYY/MM/DD')} - ${dayjs()
.subtract(31, 'day')
.format('YYYY/MM/DD')}`,
})}
percentage={percentage}
/>
);
};
export default ThirtyDaysIssueCard;
@@ -0,0 +1,80 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const TodayFeedbackCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().startOf('day').toISOString(),
to: dayjs().endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="feedback"
data={(currentData?.count ?? 0).toLocaleString()}
title={t('dashboard-card.today-feedback.title')}
description={t('dashboard-card.today-feedback.description', {
targetDate: dayjs().format('YYYY/MM/DD'),
compareDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
})}
percentage={percentage}
/>
);
};
export default TodayFeedbackCard;
@@ -0,0 +1,80 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const TodayIssueCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().startOf('day').toISOString(),
to: dayjs().endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="issue"
data={currentData?.count ?? 0}
title={t('dashboard-card.today-issue.title')}
description={t('dashboard-card.today-issue.description', {
targetDate: dayjs().format('YYYY/MM/DD'),
compareDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
})}
percentage={percentage}
/>
);
};
export default TodayIssueCard;
@@ -0,0 +1,59 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const TotalFeedbackCard: React.FC<IProps> = ({ projectId, from, to }) => {
const { t } = useTranslation();
const { data } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs(from).startOf('day').toISOString(),
to: dayjs(to).endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
return (
<DashboardCard
type="feedback"
data={data?.count ?? 0}
title={t('dashboard-card.total-feedback.title')}
description={t('dashboard-card.total-feedback.description', {
targetDate: `${dayjs(from).format('YYYY/MM/DD')} - ${dayjs(to).format(
'YYYY/MM/DD',
)}`,
})}
/>
);
};
export default TotalFeedbackCard;
@@ -0,0 +1,59 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
from: Date;
to: Date;
}
const TotalIssueCard: React.FC<IProps> = ({ from, to, projectId }) => {
const { t } = useTranslation();
const { data } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs(from).startOf('day').toISOString(),
to: dayjs(to).endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
return (
<DashboardCard
type="issue"
data={data?.count ?? 0}
title={t('dashboard-card.total-issue.title')}
description={t('dashboard-card.total-issue.description', {
targetDate: `${dayjs(from).format('YYYY/MM/DD')} - ${dayjs(to).format(
'YYYY/MM/DD',
)}`,
})}
/>
);
};
export default TotalIssueCard;
@@ -0,0 +1,79 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const YesterdayFeedbackCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/feedback/count',
variables: {
from: dayjs().subtract(2, 'day').startOf('day').toISOString(),
to: dayjs().subtract(2, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="feedback"
data={currentData?.count ?? 0}
title={t('dashboard-card.yesterday-feedback.title')}
description={t('dashboard-card.yesterday-feedback.description', {
targetDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
compareDate: dayjs().subtract(2, 'day').format('YYYY/MM/DD'),
})}
percentage={percentage}
/>
);
};
export default YesterdayFeedbackCard;
@@ -0,0 +1,79 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { DashboardCard, useOAIQuery } from '@/shared';
interface IProps {
projectId: number;
}
const YesterdayIssueCard: React.FC<IProps> = ({ projectId }) => {
const { t } = useTranslation();
const { data: currentData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const { data: previousData } = useOAIQuery({
path: '/api/admin/statistics/issue/count',
variables: {
from: dayjs().subtract(2, 'day').startOf('day').toISOString(),
to: dayjs().subtract(2, 'day').endOf('day').toISOString(),
projectId,
},
queryOptions: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
},
});
const percentage = useMemo(() => {
if (!currentData || !previousData) return 0;
return (
((currentData.count - previousData.count) / previousData.count) * 100
);
}, [currentData, previousData]);
return (
<DashboardCard
type="issue"
data={currentData?.count ?? 0}
title={t('dashboard-card.yesterday-issue.title')}
description={t('dashboard-card.yesterday-issue.description', {
targetDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
compareDate: dayjs().subtract(2, 'day').format('YYYY/MM/DD'),
})}
percentage={percentage}
/>
);
};
export default YesterdayIssueCard;
@@ -0,0 +1,128 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { ColumnDef, DisplayColumnDef } from '@tanstack/react-table';
import { createColumnHelper } from '@tanstack/react-table';
import { Icon } from '@ufb/react';
import { cn, ExpandableText, TableCheckbox } from '@/shared';
import { FieldFormatLabel } from '@/entities/field';
import type { FieldInfo } from '@/entities/field';
import { FIELD_FORMAT_ICON_MAP } from '../field/field.constant';
import type { Issue } from '../issue';
import type { Feedback } from './feedback.type';
import FeedbackCell from './ui/feedback-cell';
import IssueCell from './ui/issue-cell';
const columnHelper = createColumnHelper<Feedback>();
export const getColumns = (
fieldData: FieldInfo[],
isPreview?: boolean,
): ColumnDef<Feedback>[] =>
[
columnHelper.display({
id: 'feedback-checkbox',
header: ({ table }) => (
<TableCheckbox
{...{
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onCheckedChange: (checked) => table.toggleAllRowsSelected(checked),
}}
/>
),
cell: ({ row }) => (
<TableCheckbox
{...{
checked: row.getIsSelected(),
disabled: !row.getCanSelect(),
indeterminate: row.getIsSomeSelected(),
onCheckedChange: (checked) => row.toggleSelected(checked),
}}
/>
),
size: 50,
enableResizing: false,
}),
].concat(
fieldData.map((field) =>
field.key === 'id' ?
columnHelper.accessor('id', {
id: 'id',
size: 100,
minSize: 100,
header: () => (
<div className="flex items-center gap-1">
<Icon name={FIELD_FORMAT_ICON_MAP.number} size={16} />
{fieldData.find((v) => v.key === 'id')?.name}
</div>
),
cell: (info) => (
<ExpandableText isExpanded={info.row.getIsExpanded()}>
{info.getValue() as number}
</ExpandableText>
),
enableSorting: false,
})
: field.key === 'issues' ?
columnHelper.accessor('issues', {
id: 'issues',
size: 150,
minSize: 150,
header: () => <FieldFormatLabel format="multiSelect" name="Issue" />,
cell: ({ getValue, row }) => (
<div className={cn({ 'overflow-hidden': !row.getIsExpanded() })}>
<div className={cn('flex', { 'w-max': !row.getIsExpanded() })}>
<IssueCell
issues={getValue() as Issue[]}
feedbackId={Number(row.id)}
isPreview={isPreview}
/>
</div>
</div>
),
enableSorting: false,
})
: columnHelper.accessor(field.key, {
id: field.key,
size: field.format === 'text' ? 200 : 150,
minSize: 75,
header: () => (
<FieldFormatLabel format={field.format} name={field.name} />
),
cell: (info) => (
<FeedbackCell
field={field}
isExpanded={info.row.getIsExpanded()}
value={info.getValue()}
feedbackId={Number(info.row.id)}
/>
),
meta: {
truncate: !(
field.format === 'select' ||
field.format === 'multiSelect' ||
field.format === 'images'
),
},
enableSorting:
field.format === 'date' &&
(field.key === 'createdAt' || field.key === 'updatedAt'),
}),
) as DisplayColumnDef<Feedback>[],
);
@@ -0,0 +1,17 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export type Feedback = Record<string, unknown>;
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export * from './lib';
export * from './ui';
export * from './feedback.type';
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as useFeedbackSearch } from './use-feedback-search';
export { default as usePreviewFeedback } from './use-preview-feedback';
export { default as useFeedbackQueryConverter } from './use-feedback-query-converter';
export { default as useAIFIeldFeedbackCellLoading } from './use-ai-field-feedback-cell-loading';
@@ -0,0 +1,25 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { create } from 'zustand';
const useAIFIeldFeedbackCellLoading = create<{
loadingFeedbackIds: Set<number>;
setLoadingFeedbackIds: (ids: number[]) => void;
}>((set) => ({
loadingFeedbackIds: new Set(),
setLoadingFeedbackIds: (ids) => set({ loadingFeedbackIds: new Set(ids) }),
}));
export default useAIFIeldFeedbackCellLoading;
@@ -0,0 +1,68 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import type { IFetchError, OAIPathParameters, OAIRequestBody } from '@/shared';
import { client } from '@/shared';
type IPath = OAIPathParameters<
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/export',
'post'
>;
type IBody = OAIRequestBody<
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/export',
'post'
>;
interface IInput {
params: IPath;
options?: Omit<
UseMutationOptions<void, IFetchError, IBody, unknown>,
'mutationKey' | 'mutationFn'
>;
}
const useFeedbackDownload = ({ params, options }: IInput) => {
return useMutation({
mutationFn: async (body: IBody) => {
const { data, headers } = await client.post({
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/export',
pathParams: params,
body,
config: { responseType: 'arraybuffer' },
});
const filename = ((headers['content-disposition'] ?? '') as string).split(
'filename=',
)[1];
if (!filename || typeof data !== 'object') return;
const decodedFilename = decodeURI(filename.slice(1, filename.length - 1));
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', decodedFilename);
document.body.appendChild(link);
link.click();
link.remove();
},
...options,
});
};
export default useFeedbackDownload;
@@ -0,0 +1,235 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo } from 'react';
import dayjs from 'dayjs';
import { createParser, parseAsStringLiteral, useQueryState } from 'nuqs';
import type {
DateRangeType,
SearchQuery,
TableFilter,
TableFilterField,
TableFilterFieldFotmat,
TableFilterOperator,
} from '@/shared';
import { client, TableFilterOperators } from '@/shared';
const toQuery = (
projectId: number,
): Record<TableFilterFieldFotmat, (valeu: unknown) => unknown> => ({
number: (value) => Number(value),
issue: async (value) => {
if (typeof value === 'string') {
const { data } = await client.post({
path: '/api/admin/projects/{projectId}/issues/search',
pathParams: { projectId },
body: {
queries: [{ key: 'name', value, condition: 'IS' }] as SearchQuery[],
},
});
const result = data?.items.find((v) => v.name === value);
return result ? [result.id] : [];
}
if (Array.isArray(value)) {
return await Promise.all(
value.map(async (v: string) => {
const issueId = parseInt(v);
if (!isNaN(issueId)) return issueId;
const { data } = await client.post({
path: '/api/admin/projects/{projectId}/issues/search',
pathParams: { projectId },
body: {
queries: [
{ key: 'name', value: v, condition: 'IS' },
] as SearchQuery[],
},
});
return data?.items.find((vv) => vv.name === v)?.id;
}),
);
}
},
string: (value) => value,
select: (value) => value,
date: (value) => value,
ticket: (value) => value,
multiSelect: (value) => value,
});
const DEFAULT_DATE_RANGE = (
feedbackSearchMaxDays: number,
): SearchQuery | null =>
feedbackSearchMaxDays === -1 ? null : (
{
key: 'createdAt',
value: {
gte: dayjs()
.subtract(feedbackSearchMaxDays - 1, 'day')
.startOf('day')
.toDate(),
lt: dayjs().endOf('day').toDate(),
},
condition: 'BETWEEN',
}
);
const useFeedbackQueryConverter = (input: {
projectId: number;
filterFields: TableFilterField[];
feedbackSearchMaxDays: number;
}) => {
const { projectId, filterFields, feedbackSearchMaxDays } = input;
const defaultDateRange = DEFAULT_DATE_RANGE(feedbackSearchMaxDays);
const [operator, setOperator] = useQueryState<TableFilterOperator>(
'operator',
parseAsStringLiteral(TableFilterOperators).withDefault('AND'),
);
const [queries, setQueries] = useQueryState<SearchQuery[]>(
'queries',
createParser({
parse: (value) => JSON.parse(value) as SearchQuery[],
serialize: (value) => JSON.stringify(value),
}).withDefault([]),
);
const [defaultQueries, setDefaultQueries] = useQueryState<SearchQuery[]>(
'defaultQueries',
createParser({
parse: (value) => JSON.parse(value) as SearchQuery[],
serialize: (value) => JSON.stringify(value),
}).withDefault(defaultDateRange ? [defaultDateRange] : []),
);
const dateRange = useMemo(() => {
const dateQuery = defaultQueries.find((v) => v.key === 'createdAt');
const createdAt = dateQuery?.value as
| { gte: string; lt: string }
| undefined;
return createdAt ?
{
startDate: dayjs(createdAt.gte).toDate(),
endDate: dayjs(createdAt.lt).toDate(),
}
: null;
}, [defaultQueries]);
const onChangeDateRange = useCallback(async (value: DateRangeType) => {
if (!value) {
await setDefaultQueries([]);
return;
}
await setDefaultQueries([
{
key: 'createdAt',
value: {
gte: dayjs(value.startDate).toISOString(),
lt: dayjs(value.endDate).toISOString(),
},
condition: 'BETWEEN',
},
]);
}, []);
const tableFilters = useMemo(() => {
return queries
.map(({ key, value, condition }) => {
const field = filterFields.find((v) => v.key === key);
if (!field) return null;
const { format, name } = field;
return { key, name, value, format, condition };
})
.filter((v) => !!v) as TableFilter[];
}, [queries, filterFields]);
const onChangeTableFilters = useCallback(
async (tableFilters: TableFilter[], operator: TableFilterOperator) => {
const result = await Promise.all(
tableFilters.map(async ({ condition, format, key, value }) => ({
key,
value: await toQuery(projectId)[format](value),
condition,
})),
);
if (result.length === 0 && !dateRange) {
await setDefaultQueries(defaultDateRange ? [defaultDateRange] : []);
await setQueries([]);
await setOperator('AND');
return;
}
await setOperator(operator);
await setQueries(result);
},
[dateRange],
);
const filteredQueries = queries.filter((query) =>
filterFields.some((field) => field.key === query.key),
);
const filteredTableFilters = tableFilters.filter((v) =>
filterFields.some((vv) => vv.key === v.key),
);
useEffect(() => {
const createdAtQuery = defaultQueries.find((query) => query.key === 'createdAt');
if (feedbackSearchMaxDays === -1) {
if (createdAtQuery) {
void setDefaultQueries([]);
}
return;
}
if (!createdAtQuery && defaultDateRange) {
void setDefaultQueries([defaultDateRange]);
return;
}
const diff = dayjs(dateRange?.endDate).diff(
dayjs(dateRange?.startDate),
'day',
);
if (diff >= feedbackSearchMaxDays) {
void onChangeDateRange({
startDate: dayjs()
.subtract(feedbackSearchMaxDays - 1, 'day')
.toDate(),
endDate: dayjs().toDate(),
});
}
}, [defaultDateRange, defaultQueries, feedbackSearchMaxDays]);
return {
queries: filteredQueries,
defaultQueries,
tableFilters: filteredTableFilters,
operator,
dateRange,
updateTableFilters: onChangeTableFilters,
updateDateRange: onChangeDateRange,
};
};
export default useFeedbackQueryConverter;
@@ -0,0 +1,68 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type {
IFetchError,
OAIMutationResponse,
OAIRequestBody,
SearchQuery,
} from '@/shared';
import { client } from '@/shared';
type TData = OAIMutationResponse<
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
'post'
>;
interface IBody
extends Omit<
OAIRequestBody<
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
'post'
>,
'queries'
> {
queries: SearchQuery[];
}
const useFeedbackSearch = (
projectId: number,
channelId: number,
body: IBody,
options?: Omit<UseQueryOptions<TData, IFetchError>, 'queryKey' | 'queryFn'>,
) => {
return useQuery<TData, IFetchError>({
queryKey: [
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
projectId,
channelId,
body,
],
queryFn: async () => {
const { data: result } = await client.post({
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
pathParams: { projectId, channelId },
body,
});
return result;
},
...options,
});
};
export default useFeedbackSearch;
@@ -0,0 +1,102 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import { faker } from '@faker-js/faker';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { ISSUES } from '@/shared';
import type { Category } from '@/entities/category';
import type { FieldInfo } from '@/entities/field';
import type { Issue } from '@/entities/issue';
import type { Feedback } from '../feedback.type';
const usePreviewFeedback = (fields: FieldInfo[]) => {
const { t } = useTranslation();
const feedbacks = useMemo(() => {
const feedbacks: Feedback[] = [];
const categories: Category[] = new Array(5).fill(0).map((_, i) => ({
id: i,
name: faker.word.sample(),
}));
const issues: Issue[] = faker.helpers
.uniqueArray(() => faker.word.sample(), 10)
.map((v) => ({
id: faker.number.int(),
createdAt: faker.date.recent().toString(),
description: faker.lorem.sentence(),
feedbackCount: faker.number.int(),
updatedAt: faker.date.recent().toString(),
name: v,
status: faker.helpers.arrayElement(ISSUES(t).map((v) => v.key)),
category: faker.helpers.arrayElement(categories),
}));
for (let i = 1; i <= 10; i++) {
const fakeData: Feedback = {
id: i,
createdAt: dayjs().add(i, 'hour').toString(),
updatedAt: dayjs().add(i, 'hour').toString(),
issues: faker.helpers.arrayElements(issues, {
min: 0,
max: 4,
}),
};
for (const field of fields) {
if (
field.key === 'id' ||
field.key === 'createdAt' ||
field.key === 'updatedAt' ||
field.key === 'issues'
) {
continue;
}
fakeData[field.key] =
field.format === 'date' ? faker.date.anytime()
: field.format === 'keyword' ? faker.word.noun()
: field.format === 'multiSelect' ?
faker.helpers.arrayElements(
(field.options ?? []).map((v) => v.name),
)
: field.format === 'select' ?
(field.options ?? []).length > 0 ?
faker.helpers.arrayElement(
(field.options ?? []).map((v) => v.name),
)
: undefined
: field.format === 'number' ? faker.number.int()
: field.format === 'text' ? faker.lorem.text()
: field.format === 'aiField' ?
{
status: 'success',
message: 'The AI response results will be displayed here.',
}
: faker.helpers.arrayElements(
Array.from({
length: 15,
}).fill('/assets/images/sample_image.png'),
);
}
feedbacks.push(fakeData);
}
return feedbacks;
}, [fields]);
return feedbacks;
};
export default usePreviewFeedback;
@@ -0,0 +1,233 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { Badge, Button, ComboboxGroup, ComboboxItem, Icon } from '@ufb/react';
import type { TableFilterCondition } from '@/shared';
import {
client,
cn,
GRADIENT_CSS,
useOAIMutation,
useOAIQuery,
} from '@/shared';
import type { Issue, IssueStatus } from '@/entities/issue';
interface Props {
projectId: number;
channelId: number;
feedbackId: number;
onSelect?: (issueItem: AIIssueRecommendationItem) => void;
currentIssues: Issue[];
}
type AIIssueRecommendationItem = {
id?: number;
name: string;
option: 'ADDED' | 'CREATE' | 'EXISTING';
status?: IssueStatus;
};
const AiIssueComboboxGroup = ({
projectId,
channelId,
feedbackId,
onSelect,
currentIssues,
}: Props) => {
const { t } = useTranslation();
const [issues, setIssues] = useState<AIIssueRecommendationItem[]>([]);
const [searchedIssues, setSearchedIssues] = useState<Issue[]>([]);
const [status, setStatus] = useState<
'success' | 'error' | 'pending' | 'idle'
>('idle');
const [errorMessage, setErrorMessage] = useState('');
const { data: aiIntegrations } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/integrations',
variables: { projectId },
});
const { data: aiIssueTemplates } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/issueTemplates',
variables: { projectId },
});
const { mutateAsync: recommendIssues, isPending } = useOAIMutation({
method: 'post',
path: '/api/admin/projects/{projectId}/ai/issueRecommend/{feedbackId}',
pathParams: { projectId, feedbackId },
queryOptions: {
async onSuccess(data) {
if (!data) return;
if (data.success) {
setStatus('success');
await getIssueRecommendationItems(data.result);
}
if (!data.success) {
setStatus('error');
setErrorMessage(data.message);
}
},
},
});
const getIssueRecommendationItems = async (
input: { issueName: string }[],
) => {
const issueNames = input.map((v) => v.issueName);
const { data: searchedIssuesResponse } = await client.post({
path: '/api/admin/projects/{projectId}/issues/search',
pathParams: { projectId },
body: {
operator: 'OR',
queries: issueNames.map((name) => ({
key: 'name',
condition: 'IS' as TableFilterCondition,
value: name,
})),
},
});
const searchedIssuesList = searchedIssuesResponse?.items ?? [];
setSearchedIssues(searchedIssuesList);
const result: AIIssueRecommendationItem[] = input.map(({ issueName }) => {
const existingIssue = currentIssues.find(
({ name }) => name === issueName,
);
if (existingIssue) {
return {
id: existingIssue.id,
name: issueName,
option: 'ADDED',
status: existingIssue.status,
};
}
const matchedIssue = searchedIssuesList.find(
({ name }) => name === issueName,
);
if (matchedIssue) {
return {
id: matchedIssue.id,
name: issueName,
option: 'EXISTING',
status: matchedIssue.status,
};
}
return { name: issueName, option: 'CREATE' };
});
setIssues(result);
};
useEffect(() => {
setIssues((prev) =>
prev.map((issue) => {
if (currentIssues.some(({ name }) => name === issue.name)) {
return { ...issue, option: 'ADDED' };
}
if (searchedIssues.some(({ name }) => name === issue.name)) {
return { ...issue, option: 'EXISTING' };
}
return { ...issue, option: 'CREATE' };
}),
);
}, [currentIssues, searchedIssues]);
const aiIssue = aiIssueTemplates?.find((v) => v.channelId === channelId);
if (!aiIntegrations?.apiKey) {
return <></>;
}
return (
<ComboboxGroup
className="border-neutral-tertiary border-b"
heading={
status === 'success' && (
<div className="flex items-center justify-between">
<span className="text-neutral-tertiary text-base-normal">
AI Recommend
</span>
<p
className="text-small-normal cursor-pointer"
onClick={() => recommendIssues(undefined)}
>
Retry
</p>
</div>
)
}
>
{status !== 'success' && (
<div className="px-2 py-1">
<Button
size="small"
className="w-full"
onClick={() => recommendIssues(undefined)}
style={GRADIENT_CSS.primary}
disabled={!aiIssue?.isEnabled}
loading={isPending}
>
<Icon name="RiSparklingFill" />
Run AI
</Button>
</div>
)}
<p className="text-neutral-tertiary text-small-normal px-2 py-1 text-center">
{!aiIssue?.isEnabled ?
t('v2.description.ai-issue-recommendation-setting-required')
: status === 'idle' ?
t('v2.text.ai-issue-ready')
: status === 'pending' ?
t('v2.text.ai-issue-pending')
: ''}
</p>
{status === 'success' &&
issues.map((issue) => (
<ComboboxItem
key={issue.name}
value={issue.name}
onSelect={() => onSelect?.(issue)}
className={cn('group justify-between gap-4', {
'cursor-not-allowed opacity-50': issue.option === 'ADDED',
})}
disabled={issue.option === 'ADDED'}
>
<Badge style={GRADIENT_CSS.primary}>{issue.name}</Badge>
{issue.option === 'ADDED' ?
<Icon name="RiCheckLine" size={16} />
: <span className="text-small-normal text-neutral-tertiary opacity-0 transition-opacity group-hover:opacity-100">
{issue.option === 'CREATE' && 'Create'}
{issue.option === 'EXISTING' && 'Add'}
</span>
}
</ComboboxItem>
))}
{status === 'error' && (
<div className="px-2 py-1 text-red-500">{errorMessage}</div>
)}
</ComboboxGroup>
);
};
export default AiIssueComboboxGroup;
@@ -0,0 +1,156 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import dayjs from 'dayjs';
import Linkify from 'linkify-react';
import { Badge, Icon, Tag } from '@ufb/react';
import { DATE_TIME_FORMAT, ExpandableText, ImagePreviewButton } from '@/shared';
import { AICell } from '@/entities/ai';
import type { FieldInfo } from '@/entities/field';
import { useAIFIeldFeedbackCellLoading } from '..';
interface IProps {
isExpanded: boolean;
field: FieldInfo;
value: unknown;
feedbackId: number;
}
const toRenderableText = (value: unknown) => {
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (value && typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
return undefined;
};
const FeedbackCell: React.FC<IProps> = memo((props) => {
const { isExpanded, field, value, feedbackId } = props;
const renderableText = toRenderableText(value);
return (
<ExpandableText isExpanded={isExpanded}>
{typeof value === 'undefined' || value === null ?
undefined
: <>
{field.format === 'date' &&
dayjs(value as string).format(DATE_TIME_FORMAT)}
{field.format === 'multiSelect' && (
<div className="flex gap-2">
{(value as string[])
.sort(
(aKey, bKey) =>
(field.options ?? []).findIndex(
(option) => option.key === aKey,
) -
(field.options ?? []).findIndex(
(option) => option.key === bKey,
),
)
.map((key) => (
<Badge variant="subtle" key={key}>
{
(field.options?.find((option) => option.key === key)
?.name ?? value) as string
}
</Badge>
))}
</div>
)}
{field.format === 'select' && (
<Badge variant="subtle">
{field.options?.find((option) => option.key === value)?.name ??
''}
</Badge>
)}
{field.format === 'images' && (
<>
{Array.isArray(value) && (value as string[]).length > 0 ?
<ImagePreviewButton urls={value}>
<Tag
aria-label="View images"
variant="outline"
size="small"
className="cursor-pointer gap-1"
onClick={(e) => e.stopPropagation()}
>
<Icon name="RiImageFill" size={12} />
Image
</Tag>
</ImagePreviewButton>
: '-'}
</>
)}
{field.format === 'text' && (
<Linkify
options={{
className: 'text-blue-500 underline',
target: '_blank',
rel: 'noopener noreferrer',
attributes: {
onClick: (e: React.MouseEvent) => e.stopPropagation(),
},
}}
>
{renderableText}
</Linkify>
)}
{field.format === 'keyword' && renderableText}
{field.format === 'number' && renderableText}
{field.format === 'aiField' && (
<FeedbackAIFieldCell
value={
value as
| { status: 'loading' | 'success' | 'error'; message: string }
| undefined
}
feedbackId={feedbackId}
field={field}
/>
)}
</>
}
</ExpandableText>
);
});
const FeedbackAIFieldCell: React.FC<{
value?: { status: 'loading' | 'success' | 'error'; message: string };
feedbackId: number;
field: FieldInfo;
}> = memo((props) => {
const { field, value, feedbackId } = props;
const loadingFeedbackIds = useAIFIeldFeedbackCellLoading(
(state) => state.loadingFeedbackIds,
);
return (
<AICell
value={value}
isLoading={
loadingFeedbackIds.has(feedbackId) && field.status === 'ACTIVE'
}
/>
);
});
export default FeedbackCell;
@@ -0,0 +1,581 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo, useState } from 'react';
import { useOverlay } from '@toss/use-overlay';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import {
Button,
Divider,
Icon,
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
Tag,
toast,
} from '@ufb/react';
import {
DeleteDialog,
SUPPORT_TEST_ADMIN_USER_EMAIL,
SUPPORT_TEST_TENANT_ID,
getSupportWorkspaceCodeByProjectChannel,
isObjectEqual,
SheetDetailTable,
usePermissions,
} from '@/shared';
import type { SheetDetailTableRow } from '@/shared/ui/sheet-detail-table.ui';
import type { FieldInfo } from '@/entities/field';
import { DEFAULT_FIELD_KEYS } from '@/entities/field/field.constant';
import type { Feedback } from '../feedback.type';
interface Props {
isOpen: boolean;
close: () => void;
fields: FieldInfo[];
feedback: Feedback;
onClickDelete?: () => Promise<unknown>;
updateFeedback?: (feedback: Feedback) => Promise<unknown>;
projectId: number;
channelId: number;
refetchFeedback?: () => Promise<unknown>;
}
interface SupportTicketCommentRecord {
comment_id: number;
ticket_id: number;
author_id: string;
author_tenant_id: string;
author_name: string;
content: string;
is_internal: boolean;
created_at: string;
updated_at: string;
edited_at: string | null;
}
const FeedbackDetailSheet = (props: Props) => {
const {
close,
feedback,
fields,
isOpen,
onClickDelete,
projectId,
updateFeedback,
channelId,
refetchFeedback,
} = props;
const { t } = useTranslation();
const perms = usePermissions();
const [currentFeedback, setCurrentFeedback] = useState(feedback);
const [mode, setMode] = useState<'edit' | 'view'>('view');
const overlay = useOverlay();
const [isLoading, setIsLoading] = useState(false);
const [comments, setComments] = useState<SupportTicketCommentRecord[]>([]);
const [commentDraft, setCommentDraft] = useState('');
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [editingCommentDraft, setEditingCommentDraft] = useState('');
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
const [isCommentSaving, setIsCommentSaving] = useState(false);
const feedbackId = Number(feedback.id);
const workspaceCode = useMemo(
() => getSupportWorkspaceCodeByProjectChannel(projectId, channelId),
[channelId, projectId],
);
useEffect(() => {
setCurrentFeedback(feedback);
}, [feedback]);
useEffect(() => {
if (!isOpen || !workspaceCode || !Number.isFinite(feedbackId)) {
setComments([]);
return;
}
const fetchComments = async () => {
setIsCommentsLoading(true);
try {
const response = await fetch(
`/api/support/tickets/${feedbackId}/comments?workspaceCode=${workspaceCode}`,
);
const data = (await response.json()) as
| SupportTicketCommentRecord[]
| { message?: string };
if (!response.ok) {
throw new Error(
'message' in data && data.message ? data.message : '댓글을 불러오지 못했습니다.',
);
}
setComments(data as SupportTicketCommentRecord[]);
} catch (error) {
toast.error(
error instanceof Error ? error.message : '댓글 조회 중 오류가 발생했습니다.',
);
} finally {
setIsCommentsLoading(false);
}
};
void fetchComments();
}, [feedbackId, isOpen, workspaceCode]);
const onClickCancel = () => {
setMode('view');
setCurrentFeedback(feedback);
};
const onClickSubmit = async () => {
const editedFeedback = fields
.filter((v) => v.property === 'EDITABLE' && v.status === 'ACTIVE')
.reduce((acc, cur) => {
if (cur.key === 'issues') return acc;
if (cur.format === 'date') {
return {
...acc,
[cur.key]:
currentFeedback[cur.key] ?
dayjs(currentFeedback[cur.key] as string).toISOString()
: null,
};
}
if (cur.format === 'multiSelect') {
return {
...acc,
[cur.key]: currentFeedback[cur.key] ?? [],
};
}
if (cur.format === 'select') {
return {
...acc,
[cur.key]: currentFeedback[cur.key] ?? null,
};
}
return { ...acc, [cur.key]: currentFeedback[cur.key] };
}, {} as Feedback);
try {
await updateFeedback?.(editedFeedback);
} finally {
setIsLoading(false);
setMode('view');
}
};
const openDeleteDialog = () => {
overlay.open(({ close: dialogClose, isOpen }) => (
<DeleteDialog
close={dialogClose}
isOpen={isOpen}
onClickDelete={async () => {
await onClickDelete?.();
dialogClose();
close();
}}
/>
));
};
const handleCreateComment = async () => {
if (!workspaceCode || !Number.isFinite(feedbackId) || isCommentSaving) {
return;
}
const nextContent = commentDraft.trim();
if (!nextContent) {
toast.error('댓글 내용을 입력해 주세요.');
return;
}
setIsCommentSaving(true);
try {
const response = await fetch(
`/api/support/tickets/${feedbackId}/comments?workspaceCode=${workspaceCode}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
requester_id: SUPPORT_TEST_ADMIN_USER_EMAIL,
requester_tenant_id: SUPPORT_TEST_TENANT_ID,
author_name: 'Admin User',
content: nextContent,
is_internal: true,
}),
},
);
const data = (await response.json()) as SupportTicketCommentRecord | { message?: string };
if (!response.ok) {
throw new Error(
'message' in data && data.message ? data.message : '댓글 저장에 실패했습니다.',
);
}
setComments((current) => [...current, data as SupportTicketCommentRecord]);
setCommentDraft('');
toast.success('관리자 댓글이 저장되었습니다.');
} catch (error) {
toast.error(
error instanceof Error ? error.message : '댓글 저장 중 오류가 발생했습니다.',
);
} finally {
setIsCommentSaving(false);
}
};
const handleUpdateComment = async (commentId: number) => {
if (!workspaceCode || !Number.isFinite(feedbackId) || isCommentSaving) {
return;
}
const nextContent = editingCommentDraft.trim();
if (!nextContent) {
toast.error('댓글 내용을 입력해 주세요.');
return;
}
setIsCommentSaving(true);
try {
const response = await fetch(
`/api/support/tickets/${feedbackId}/comments/${commentId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
requester_id: SUPPORT_TEST_ADMIN_USER_EMAIL,
requester_tenant_id: SUPPORT_TEST_TENANT_ID,
content: nextContent,
}),
},
);
const data = (await response.json()) as SupportTicketCommentRecord | { message?: string };
if (!response.ok) {
throw new Error(
'message' in data && data.message ? data.message : '댓글 수정에 실패했습니다.',
);
}
setComments((current) =>
current.map((comment) =>
comment.comment_id === commentId ? (data as SupportTicketCommentRecord) : comment,
),
);
setEditingCommentId(null);
setEditingCommentDraft('');
toast.success('관리자 댓글이 수정되었습니다.');
} catch (error) {
toast.error(
error instanceof Error ? error.message : '댓글 수정 중 오류가 발생했습니다.',
);
} finally {
setIsCommentSaving(false);
}
};
const handleDeleteComment = async (commentId: number) => {
if (!workspaceCode || !Number.isFinite(feedbackId) || isCommentSaving) {
return;
}
if (!window.confirm('이 댓글을 삭제하시겠습니까?')) {
return;
}
setIsCommentSaving(true);
try {
const query = new URLSearchParams({
requesterId: SUPPORT_TEST_ADMIN_USER_EMAIL,
requesterTenantId: SUPPORT_TEST_TENANT_ID,
});
const response = await fetch(
`/api/support/tickets/${feedbackId}/comments/${commentId}?${query.toString()}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
const data = (await response.json()) as { message?: string };
throw new Error(data.message ?? '댓글 삭제에 실패했습니다.');
}
setComments((current) =>
current.filter((comment) => comment.comment_id !== commentId),
);
if (editingCommentId === commentId) {
setEditingCommentId(null);
setEditingCommentDraft('');
}
toast.success('관리자 댓글이 삭제되었습니다.');
} catch (error) {
toast.error(
error instanceof Error ? error.message : '댓글 삭제 중 오류가 발생했습니다.',
);
} finally {
setIsCommentSaving(false);
}
};
return (
<Sheet open={isOpen} onOpenChange={close}>
<SheetContent className="max-w-[800px]">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
{t('v2.text.name.detail', { name: 'Feedback' })}
<Tag
variant="outline"
size="small"
className="cursor-pointer"
onClick={async () => {
await navigator.clipboard.writeText(
`${window.location.origin}/${window.location.pathname}?queries=${JSON.stringify([{ key: 'id', value: feedback.id, condition: 'IS' }])}&channelId=${channelId}`,
);
toast(t('v2.toast.copy'), {
icon: <Icon name="RiCheckboxMultipleFill" />,
});
}}
>
Copy URL
</Tag>
</SheetTitle>
</SheetHeader>
<SheetBody>
<SheetDetailTable
data={currentFeedback}
rows={
fields
.filter((v) => DEFAULT_FIELD_KEYS.includes(v.key))
.map((v) =>
v.key === 'issues' ?
{ ...v, format: 'issue', feedbackId: feedback.id }
: v.format === 'aiField' ?
{ ...v, format: 'aiField', refetch: refetchFeedback }
: v,
) as SheetDetailTableRow[]
}
/>
<Divider variant="subtle" className="my-4" />
<SheetDetailTable
rows={
fields
.filter((v) => !DEFAULT_FIELD_KEYS.includes(v.key))
.map((v) => ({
...v,
editable: v.property === 'EDITABLE',
disabled:
v.property === 'EDITABLE' && v.status === 'INACTIVE',
refetch: v.format === 'aiField' ? refetchFeedback : undefined,
})) as SheetDetailTableRow[]
}
mode={mode}
data={currentFeedback}
onChange={(key, value) =>
setCurrentFeedback((prev) => ({ ...prev, [key]: value }))
}
/>
{workspaceCode && Number.isFinite(feedbackId) && (
<>
<Divider variant="subtle" className="my-4" />
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">Comments</p>
<p className="text-xs text-neutral-secondary">
실제 ABC 콘솔 상세 화면에서 관리자 내부 댓글을 남길 수 있습니다.
</p>
</div>
<Tag variant="outline" size="small">
{comments.length} items
</Tag>
</div>
{isCommentsLoading ? (
<div className="text-sm text-neutral-secondary">댓글을 불러오는 중입니다...</div>
) : comments.length === 0 ? (
<div className="rounded-xl border border-dashed border-neutral-border p-4 text-sm text-neutral-secondary">
아직 등록된 댓글이 없습니다.
</div>
) : (
<div className="space-y-3">
{comments
.slice()
.reverse()
.map((comment) => {
const isOwnInternalComment =
comment.is_internal &&
comment.author_id === SUPPORT_TEST_ADMIN_USER_EMAIL &&
comment.author_tenant_id === SUPPORT_TEST_TENANT_ID;
return (
<div key={comment.comment_id} className="rounded-xl border border-neutral-border bg-neutral-background p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{comment.author_name}</p>
<Tag variant={comment.is_internal ? 'secondary' : 'outline'} size="small">
{comment.is_internal ? 'Internal' : 'User'}
</Tag>
</div>
<div className="flex items-center gap-3">
<p className="text-xs text-neutral-secondary">
{dayjs(comment.created_at).format('YYYY-MM-DD HH:mm')}
</p>
{isOwnInternalComment && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setEditingCommentId(comment.comment_id);
setEditingCommentDraft(comment.content);
}}
className="text-xs font-medium text-primary"
>
수정
</button>
<button
type="button"
onClick={() => void handleDeleteComment(comment.comment_id)}
className="text-xs font-medium text-tint-red"
>
삭제
</button>
</div>
)}
</div>
</div>
{editingCommentId === comment.comment_id ? (
<div className="mt-3 space-y-3">
<textarea
value={editingCommentDraft}
onChange={(event) => setEditingCommentDraft(event.target.value)}
rows={4}
className="w-full resize-none rounded-lg border border-neutral-border bg-background px-3 py-2 text-sm outline-none transition focus:border-primary"
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setEditingCommentId(null);
setEditingCommentDraft('');
}}
>
취소
</Button>
<Button
onClick={() => void handleUpdateComment(comment.comment_id)}
loading={isCommentSaving}
>
수정 저장
</Button>
</div>
</div>
) : (
<p className="mt-2 whitespace-pre-line text-sm">{comment.content}</p>
)}
</div>
);
})}
</div>
)}
<div className="space-y-2 rounded-xl border border-neutral-border p-4">
<p className="text-sm font-medium">관리자 댓글 작성</p>
<textarea
value={commentDraft}
onChange={(event) => setCommentDraft(event.target.value)}
rows={4}
placeholder="처리 메모나 운영 코멘트를 남겨 주세요."
className="w-full resize-none rounded-lg border border-neutral-border bg-background px-3 py-2 text-sm outline-none transition focus:border-primary"
/>
<div className="flex justify-end">
<Button onClick={handleCreateComment} loading={isCommentSaving}>
관리자 댓글 저장
</Button>
</div>
</div>
</div>
</>
)}
</SheetBody>
<SheetFooter>
{onClickDelete && (
<div className="flex-1">
<Button
variant="destructive"
onClick={openDeleteDialog}
disabled={!perms.includes('feedback_delete')}
>
{t('v2.button.delete')}
</Button>
</div>
)}
{mode === 'edit' && (
<>
<Button variant="secondary" onClick={onClickCancel}>
{t('v2.button.cancel')}
</Button>
<Button
disabled={isObjectEqual(
{ ...feedback, updatedAt: '' },
{ ...currentFeedback, updatedAt: '' },
)}
onClick={onClickSubmit}
loading={isLoading}
>
{t('v2.button.save')}
</Button>
</>
)}
{mode === 'view' && (
<>
<SheetClose>{t('v2.button.cancel')}</SheetClose>
<Button
onClick={() => setMode('edit')}
disabled={!perms.includes('feedback_update')}
>
{t('v2.button.edit')}
</Button>
</>
)}
</SheetFooter>
</SheetContent>
</Sheet>
);
};
export default FeedbackDetailSheet;
@@ -0,0 +1,148 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { Button, Icon, Tabs, TabsList, TabsTrigger } from '@ufb/react';
interface Props {
channels: { id: number; name: string }[];
currentChannelId: number;
setCurrentChannelId: (id: number) => void;
totalItems?: number;
}
const FeedbackTableChannelSelection = (props: Props) => {
const { channels, currentChannelId, setCurrentChannelId, totalItems } = props;
const { t } = useTranslation();
const tabsContainerRef = useRef<HTMLDivElement>(null);
const [showScrollButtons, setShowScrollButtons] = useState(false);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const checkScrollState = () => {
const container = tabsContainerRef.current;
if (container) {
const isScrollable = container.scrollWidth > container.clientWidth;
setShowScrollButtons(isScrollable);
if (isScrollable) {
setShowLeftGradient(container.scrollLeft > 0);
setShowRightGradient(
0.5 + container.scrollLeft + container.clientWidth <
container.scrollWidth,
);
} else {
setShowLeftGradient(false);
setShowRightGradient(false);
}
}
};
useEffect(() => {
const container = tabsContainerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver(() => {
checkScrollState();
});
resizeObserver.observe(container);
checkScrollState();
container.addEventListener('scroll', checkScrollState);
return () => {
resizeObserver.disconnect();
container.removeEventListener('scroll', checkScrollState);
};
}, [channels, tabsContainerRef]);
const handleScroll = (direction: 'left' | 'right') => {
const container = tabsContainerRef.current;
if (container) {
const scrollAmount = 200;
if (direction === 'left') {
container.scrollLeft -= scrollAmount;
} else {
container.scrollLeft += scrollAmount;
}
}
};
return (
<div className="relative flex items-center overflow-auto">
<div className="scrollbar-hide relative flex-1 overflow-auto">
{showLeftGradient && (
<div className="pointer-events-none absolute bottom-0 left-0 top-0 z-10 w-12 bg-gradient-to-r from-[var(--bg-neutral-primary)] to-transparent" />
)}
<Tabs
value={String(currentChannelId)}
onValueChange={(v) => setCurrentChannelId(Number(v))}
className="scrollbar-hide overflow-auto"
ref={tabsContainerRef}
>
<TabsList className="scrollbar-hide">
{channels.length === 0 ?
<TabsTrigger value="-1">
{t('v2.text.no-data.channel')}
</TabsTrigger>
: channels.map((channel) => (
<TabsTrigger key={channel.id} value={String(channel.id)}>
{channel.name}
{currentChannelId === channel.id && (
<span className="ml-1 font-bold">{totalItems}</span>
)}
</TabsTrigger>
))
}
</TabsList>
</Tabs>
{showRightGradient && (
<div className="pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-12 bg-gradient-to-l from-[var(--bg-neutral-primary)] to-transparent" />
)}
</div>
{showScrollButtons && (
<div className="ml-2 flex items-center gap-2">
<Button
variant="outline"
radius="large"
onClick={() => handleScroll('left')}
aria-label="Scroll left"
disabled={!showLeftGradient}
>
<Icon name="RiArrowLeftLine" />
</Button>
<Button
variant="outline"
radius="large"
onClick={() => handleScroll('right')}
aria-label="Scroll right"
disabled={!showRightGradient}
>
<Icon name="RiArrowRightLine" />
</Button>
</div>
)}
</div>
);
};
export default FeedbackTableChannelSelection;
@@ -0,0 +1,122 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import { useRouter } from 'next/router';
import type { Table } from '@tanstack/react-table';
import { useTranslation } from 'next-i18next';
import {
Dropdown,
DropdownContent,
DropdownItem,
DropdownLabel,
DropdownTrigger,
Icon,
toast,
} from '@ufb/react';
import type { SearchQuery, TableFilterOperator } from '@/shared';
import type { Field } from '@/entities/field';
import type { Feedback } from '../feedback.type';
import useFeedbackDownload from '../lib/use-feedback-download';
interface Props {
fields: Field[];
defaultQueries: SearchQuery[];
queries: SearchQuery[];
disabled: boolean;
table: Table<Feedback>;
operator: TableFilterOperator;
totalItems: number;
}
const FeedbackTableDownload = (props: Props) => {
const {
fields,
queries,
disabled,
table,
operator,
totalItems,
defaultQueries,
} = props;
const router = useRouter();
const { channelId, projectId } = router.query;
const { t } = useTranslation();
const { mutateAsync: download } = useFeedbackDownload({
params: { channelId: Number(channelId), projectId: Number(projectId) },
});
const visibleColumns = table.getVisibleFlatColumns();
const { rowSelection } = table.getState();
const feedbackIds = useMemo(
() =>
Object.entries(rowSelection)
.filter(([_, v]) => !!v)
.map(([k]) => Number(k)),
[rowSelection],
);
const fieldIds = useMemo(
() =>
fields
.filter((v) => visibleColumns.some((column) => column.id === v.key))
.sort((a, b) => a.order - b.order)
.map((v) => v.id),
[fields, visibleColumns],
);
const exportFeedbackResponse = (type: 'xlsx' | 'csv') => () => {
void toast.promise(
download({
type,
fieldIds,
defaultQueries,
queries,
operator,
filterFeedbackIds: feedbackIds.length > 0 ? feedbackIds : undefined,
}),
{
success: () => t('main.feedback.download.success'),
error: () => t('main.feedback.download.error'),
loading: t('main.feedback.download.loading'),
},
);
};
return (
<Dropdown>
<DropdownTrigger disabled={disabled}>
<Icon name="RiDownload2Line" />
Export
</DropdownTrigger>
<DropdownContent>
<DropdownLabel>
{feedbackIds.length > 0 ? feedbackIds.length : totalItems} Items
</DropdownLabel>
<DropdownItem onClick={exportFeedbackResponse('xlsx')}>
Excel
</DropdownItem>
<DropdownItem onClick={exportFeedbackResponse('csv')}>CSV</DropdownItem>
</DropdownContent>
</Dropdown>
);
};
export default FeedbackTableDownload;
@@ -0,0 +1,43 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { Table } from '@tanstack/react-table';
import { Button, Icon } from '@ufb/react';
import { cn } from '@/shared';
import type { Feedback } from '../feedback.type';
interface Props {
table: Table<Feedback>;
}
const FeedbackTableExpand = ({ table }: Props) => {
return (
<Button
variant="outline"
className={cn({
'!bg-neutral-tertiary': table.getIsAllRowsExpanded(),
})}
onClick={() => table.toggleAllRowsExpanded()}
>
<Icon name="RiLineHeight" />
Expand
</Button>
);
};
export default FeedbackTableExpand;
@@ -0,0 +1,75 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { Table } from '@tanstack/react-table';
import {
Badge,
Combobox,
ComboboxContent,
ComboboxList,
ComboboxSelectItem,
ComboboxTrigger,
Icon,
} from '@ufb/react';
import type { FieldInfo } from '@/entities/field';
import { FieldFormatLabel } from '@/entities/field';
import type { Feedback } from '../feedback.type';
interface Props {
table: Table<Feedback>;
fields: FieldInfo[];
}
const FeedbackTableViewOptions = ({ table, fields }: Props) => {
return (
<Combobox>
<ComboboxTrigger trigger="click">
<Icon name="RiEyeLine" />
View
<Badge variant="subtle">{table.getVisibleFlatColumns().length}</Badge>
</ComboboxTrigger>
<ComboboxContent>
<ComboboxList maxHeight="400px">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide(),
)
.map((column) => {
const field = fields.find((f) => f.key === column.id);
if (!field) return null;
return (
<ComboboxSelectItem
key={column.id}
onSelect={() =>
column.toggleVisibility(!column.getIsVisible())
}
checked={column.getIsVisible()}
>
<FieldFormatLabel format={field.format} name={field.name} />
</ComboboxSelectItem>
);
})}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};
export default FeedbackTableViewOptions;
@@ -0,0 +1,415 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo, useState } from 'react';
import type {
OnChangeFn,
PaginationState,
Updater,
VisibilityState,
} from '@tanstack/react-table';
import {
getCoreRowModel,
getExpandedRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useOverlay } from '@toss/use-overlay';
import { useTranslation } from 'next-i18next';
import { Badge, Button, Icon, toast } from '@ufb/react';
import type { TableFilterField } from '@/shared';
import {
BasicTable,
DateRangePicker,
DeleteDialog,
TableFilterPopover,
TablePagination,
useOAIMutation,
usePermissions,
useSort,
} from '@/shared';
import type { Channel } from '@/entities/channel';
import type { Field } from '@/entities/field';
import { AISparklingIcon } from '@/assets';
import { getColumns } from '../feedback-table-columns';
import {
useAIFIeldFeedbackCellLoading,
useFeedbackQueryConverter,
useFeedbackSearch,
} from '../lib';
import FeedbackDetailSheet from './feedback-detail-sheet.ui';
import FeedbackTableChannelSelection from './feedback-table-channel-selection.ui';
import FeedbackTableDownload from './feedback-table-download.ui';
import FeedbackTableExpand from './feedback-table-expand.ui';
import FeedbackTableViewOptions from './feedback-table-view-options.ui';
interface Props {
projectId: number;
channels: Channel[];
currentChannel: Channel;
setCurrentChannelId: (id: number) => void;
filterFields: TableFilterField[];
fields: Field[];
}
const FeedbackTable = (props: Props) => {
const {
channels,
currentChannel,
setCurrentChannelId,
projectId,
filterFields,
fields,
} = props;
const { t } = useTranslation();
const perms = usePermissions();
const [openFeedbackId, setOpenFeedbackId] = useState<number | null>(null);
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [rowCount, setRowCount] = useState(0);
const [pageCount, setPageCount] = useState(0);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
});
const [columnVisibility, setColumnVisibility] = useState<
Record<number, VisibilityState>
>({});
const onChangeColumnVisibility: OnChangeFn<VisibilityState> = (
updaterOrValue: Updater<VisibilityState>,
) => {
if (typeof updaterOrValue !== 'function') {
setColumnVisibility({
...columnVisibility,
[currentChannel.id]: updaterOrValue,
});
} else {
setColumnVisibility((prev) => ({
...prev,
[currentChannel.id]: updaterOrValue(
columnVisibility[currentChannel.id] ?? {},
),
}));
}
};
const {
queries,
tableFilters,
dateRange,
operator,
updateTableFilters,
updateDateRange,
defaultQueries,
} = useFeedbackQueryConverter({
projectId,
filterFields,
feedbackSearchMaxDays: currentChannel.feedbackSearchMaxDays,
});
const onChangeCurrentChannelId = (channelId: number) => {
setCurrentChannelId(channelId);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
table.resetRowSelection();
};
const onChangeTableFilters: typeof updateTableFilters = async (...input) => {
await updateTableFilters(...input);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
table.resetRowSelection();
};
const onChangeDateRange: typeof updateDateRange = async (...input) => {
await updateDateRange(...input);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
table.resetRowSelection();
};
const columns = useMemo(() => getColumns(fields), [fields]);
const table = useReactTable({
columns,
data: rows,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
pageCount,
rowCount,
state: {
pagination,
columnVisibility: columnVisibility[currentChannel.id],
},
initialState: { sorting: [{ id: 'createdAt', desc: true }] },
manualSorting: true,
onPaginationChange: setPagination,
onColumnVisibilityChange: onChangeColumnVisibility,
getRowId: (row) => String(row.id),
});
const { rowSelection, sorting } = table.getState();
const sort = useSort(sorting);
const selectedRowIds = useMemo(() => {
return Object.entries(rowSelection).reduce(
(acc, [key, value]) => (value ? acc.concat(Number(key)) : acc),
[] as number[],
);
}, [rowSelection]);
const {
data: feedbackData,
isLoading,
refetch,
} = useFeedbackSearch(
projectId,
currentChannel.id,
{
queries,
operator,
sort,
page: pagination.pageIndex + 1,
limit: pagination.pageSize,
defaultQueries,
},
{
staleTime: 1000 * 60,
throwOnError(error) {
if (error.code === 'LargeWindow') {
toast.error('Please narrow down the search range.');
}
return false;
},
},
);
useEffect(() => {
setRows(feedbackData?.items ?? []);
setPageCount(feedbackData?.meta.totalPages ?? 0);
setRowCount(feedbackData?.meta.totalItems ?? 0);
table.resetRowSelection();
}, [feedbackData]);
const { mutateAsync: deleteFeedback } = useOAIMutation({
method: 'delete',
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks',
pathParams: { channelId: currentChannel.id, projectId },
queryOptions: {
onSuccess: async () => {
await refetch();
toast.success(t('v2.toast.success'));
table.resetRowSelection();
},
},
});
const currentFeedback = useMemo(
() => rows.find((v) => v.id === openFeedbackId),
[rows, openFeedbackId],
);
const { mutateAsync: updateFeedback } = useOAIMutation({
method: 'put',
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/{feedbackId}',
pathParams: {
channelId: currentChannel.id,
feedbackId: (currentFeedback?.id ?? 0) as number,
projectId,
},
queryOptions: {
onSuccess: async () => {
await refetch();
toast.success(t('v2.toast.success'));
},
},
});
return (
<div className="flex h-full flex-col gap-4">
<div className="flex flex-shrink justify-between gap-2">
<FeedbackTableChannelSelection
channels={channels}
currentChannelId={currentChannel.id}
setCurrentChannelId={onChangeCurrentChannelId}
totalItems={feedbackData?.meta.totalItems ?? 0}
/>
<div className="flex flex-shrink-0 gap-2 [&>button]:min-w-20 [&>button]:flex-shrink-0">
{selectedRowIds.length > 0 && (
<>
<FeedbackDeleteButton
selectedRowIds={selectedRowIds}
deleteFeedback={deleteFeedback}
/>
{fields.filter(
(v) => v.format === 'aiField' && v.status === 'ACTIVE',
).length > 0 && (
<RunAIButton
selectedRowIds={selectedRowIds}
projectId={projectId}
afterProcess={() => {
table.resetRowSelection();
void refetch();
}}
/>
)}
</>
)}
<DateRangePicker
onChange={onChangeDateRange}
value={dateRange}
maxDate={new Date()}
maxDays={currentChannel.feedbackSearchMaxDays}
allowEntirePeriod={currentChannel.feedbackSearchMaxDays === -1}
tooltipContent={t('tooltip.feedback-date-picker-button')}
/>
<TableFilterPopover
filterFields={filterFields.filter(
(v) =>
v.key === 'issueIds' ||
table.getAllColumns().some((column) => column.id === v.key),
)}
operator={operator}
onSubmit={onChangeTableFilters}
tableFilters={tableFilters}
table={table}
/>
<FeedbackTableViewOptions table={table} fields={fields} />
<FeedbackTableExpand table={table} />
<FeedbackTableDownload
table={table}
fields={fields}
queries={queries}
defaultQueries={defaultQueries}
disabled={!perms.includes('feedback_download_read')}
operator={operator}
totalItems={feedbackData?.meta.totalItems ?? 0}
/>
</div>
</div>
<BasicTable
table={table}
onClickRow={(_, row) => setOpenFeedbackId(row.id as number)}
isLoading={isLoading}
isFiltered={queries.length > 0}
resiable
/>
<TablePagination table={table} />
{currentFeedback && (
<FeedbackDetailSheet
updateFeedback={(feedback) => updateFeedback(feedback as never)}
onClickDelete={() =>
deleteFeedback({ feedbackIds: [currentFeedback.id as number] })
}
isOpen={!!openFeedbackId}
close={() => setOpenFeedbackId(null)}
feedback={currentFeedback}
fields={fields}
projectId={projectId}
channelId={currentChannel.id}
refetchFeedback={refetch}
/>
)}
</div>
);
};
const FeedbackDeleteButton = (props: {
selectedRowIds: number[];
deleteFeedback: (params: { feedbackIds: number[] }) => Promise<void>;
}) => {
const { selectedRowIds, deleteFeedback } = props;
const { t } = useTranslation();
const perms = usePermissions();
const overlay = useOverlay();
const openDeleteFeedbacksDialog = () => {
overlay.open(({ close, isOpen }) => (
<DeleteDialog
close={close}
isOpen={isOpen}
onClickDelete={() => deleteFeedback({ feedbackIds: selectedRowIds })}
/>
));
};
return (
<Button
variant="outline"
className="!text-tint-red"
onClick={openDeleteFeedbacksDialog}
disabled={!perms.includes('feedback_delete')}
>
<Icon name="RiDeleteBin6Line" />
{t('v2.button.name.delete', { name: 'Feedback' })}
<Badge variant="subtle" className="!text-tint-red">
{selectedRowIds.length}
</Badge>
</Button>
);
};
const RunAIButton = (props: {
selectedRowIds: number[];
projectId: number;
afterProcess: () => void;
}) => {
const { selectedRowIds, projectId, afterProcess } = props;
const { t } = useTranslation();
const perms = usePermissions();
const setLoadingFeedbackIds = useAIFIeldFeedbackCellLoading(
(state) => state.setLoadingFeedbackIds,
);
const { mutateAsync: processAI, isPending: isPendingAIProcess } =
useOAIMutation({
method: 'post',
path: '/api/admin/projects/{projectId}/ai/process',
pathParams: { projectId },
queryOptions: {
onSuccess() {
toast.success(t('v2.toast.success'));
},
onSettled() {
afterProcess();
setLoadingFeedbackIds([]);
},
onError(error) {
toast.error(error.message);
},
},
});
return (
<Button
variant="outline"
onClick={() => {
setLoadingFeedbackIds(selectedRowIds);
toast.promise(processAI({ feedbackIds: selectedRowIds }), {
loading: 'Loading',
success: () => 'Success',
});
}}
disabled={isPendingAIProcess || !perms.includes('feedback_update')}
>
<AISparklingIcon />
Run AI
<Badge variant="subtle">{selectedRowIds.length}</Badge>
</Button>
);
};
export default FeedbackTable;
@@ -0,0 +1,21 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as FeedbackDetailSheet } from './feedback-detail-sheet.ui';
export { default as FeedbackTableDownload } from './feedback-table-download.ui';
export { default as FeedbackTableExpand } from './feedback-table-expand.ui';
export { default as FeedbackTableViewOptions } from './feedback-table-view-options.ui';
export { default as FeedbackTableChannelSelection } from './feedback-table-channel-selection.ui';
export { default as FeedbackTable } from './feedback-table.ui';
@@ -0,0 +1,130 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { useRouter } from 'next/router';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import {
Badge,
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
Icon,
TextInput,
toast,
} from '@ufb/react';
import { useOAIMutation, usePermissions } from '@/shared';
import type { Issue } from '@/entities/issue';
interface Props {
issue: Issue;
}
const IssueCellEditCombobox = (props: Props) => {
const { issue } = props;
const { t } = useTranslation();
const perms = usePermissions();
const router = useRouter();
const projectId = Number(router.query.projectId);
const [inputIssueName, setInputIssueName] = useState(issue.name);
const queryClient = useQueryClient();
const refetch = async () => {
await queryClient.invalidateQueries({
queryKey: [
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
],
});
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
});
};
const { mutate: update } = useOAIMutation({
method: 'put',
path: '/api/admin/projects/{projectId}/issues/{issueId}',
pathParams: { issueId: issue.id, projectId },
queryOptions: {
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
},
});
const { mutate: deleteIssue } = useOAIMutation({
method: 'delete',
path: '/api/admin/projects/{projectId}/issues/{issueId}',
pathParams: { projectId, issueId: issue.id },
queryOptions: {
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
},
});
return (
<Dropdown>
<DropdownTrigger asChild>
<button>
<Badge onClick={(e) => e.stopPropagation()} variant="subtle">
Edit
</Badge>
</button>
</DropdownTrigger>
<DropdownContent onClick={(e) => e.stopPropagation()} side="right">
<div className="border-b">
<TextInput
className="border-none"
value={inputIssueName}
onChange={(e) => setInputIssueName(e.currentTarget.value)}
disabled={!perms.includes('issue_update')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
const name = inputIssueName.trim();
update({ ...issue, name });
setInputIssueName(name);
}
}}
/>
</div>
<DropdownItem
className="gap-2"
onSelect={() => deleteIssue(undefined)}
disabled={!perms.includes('issue_delete')}
>
<Icon name="RiDeleteBin5Fill" size={16} />
Delete Issue
</DropdownItem>
</DropdownContent>
</Dropdown>
);
};
export default IssueCellEditCombobox;
@@ -0,0 +1,299 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { useThrottle } from 'react-use';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
Icon,
Tag,
toast,
} from '@ufb/react';
import {
client,
cn,
commandFilter,
useOAIMutation,
usePermissions,
} from '@/shared';
import { IssueBadge, useIssueSearchInfinite } from '@/entities/issue';
import type { Issue } from '@/entities/issue';
import { useFeedbackSearch } from '../lib';
import AiIssueComboboxGroup from './ai-issue-combobox-group.ui';
const InfiniteScrollArea = dynamic(
() => import('@/shared/ui/infinite-scroll-area.ui'),
{ ssr: false },
);
interface IProps {
issues?: Issue[];
feedbackId?: number;
isPreview?: boolean;
}
const IssueCell: React.FC<IProps> = (props) => {
const { feedbackId, issues, isPreview = false } = props;
const { t } = useTranslation();
const router = useRouter();
const projectId = Number(router.query.projectId);
const channelId = Number(router.query.channelId);
const perms = usePermissions(projectId);
const [inputValue, setInputValue] = useState('');
const throttledvalue = useThrottle(inputValue, 500);
const queryClient = useQueryClient();
const { data } = useFeedbackSearch(
projectId,
channelId,
{
queries: [{ key: 'id', value: feedbackId, condition: 'IS' }],
operator: 'AND',
},
{ enabled: !issues },
);
const currentIssues = (issues ?? data?.items[0]?.issues ?? []) as Issue[];
const refetch = async () => {
await queryClient.invalidateQueries({
queryKey: [
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
],
});
await queryClient.invalidateQueries({
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
});
};
const {
data: allIssueData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useIssueSearchInfinite(Number(projectId), {
limit: 50,
queries: [{ key: 'name', value: throttledvalue, condition: 'CONTAINS' }],
sort: { name: 'ASC' },
});
const allIssues = useMemo(() => {
return allIssueData.pages
.map((v) => v?.items)
.filter((v) => !!v)
.flat()
.sort(
(a, b) =>
(currentIssues.some((issue) => issue.id === a.id) ? -1 : 1) -
(currentIssues.some((issue) => issue.id === b.id) ? -1 : 1),
);
}, [allIssueData, currentIssues]);
const { mutate: attatchIssue } = useMutation({
mutationFn: async ({ issueId }: { issueId: number }) => {
if (!feedbackId) return;
const { data } = await client.post({
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/{feedbackId}/issue/{issueId}',
pathParams: { projectId, channelId, feedbackId, issueId },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
const { mutate: detachIssue } = useMutation({
mutationFn: async ({ issueId }: { issueId: number }) => {
if (!feedbackId) return;
const { data } = await client.delete({
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/{feedbackId}/issue/{issueId}',
pathParams: { projectId, channelId, feedbackId, issueId },
});
return data;
},
async onSuccess() {
await refetch();
toast.success(t('v2.toast.success'));
},
onError(error) {
toast.error(error.message);
},
});
const { mutateAsync: createIssue } = useOAIMutation({
method: 'post',
path: '/api/admin/projects/{projectId}/issues',
pathParams: { projectId },
queryOptions: {
async onSuccess() {
await refetch();
setInputValue('');
toast.success(t('v2.toast.success'));
},
},
});
const onClickIssue = (issueId: number) => {
if (currentIssues.some((v) => v.id === issueId)) {
detachIssue({ issueId });
} else {
attatchIssue({ issueId });
}
};
const onCreateIssue = async (name: string) => {
const data = await createIssue({
name,
description: '',
});
if (!data) return;
attatchIssue({ issueId: data.id });
};
return (
<div
className="flex flex-wrap items-center gap-1 rounded"
onClick={(e) => e.stopPropagation()}
>
<Combobox>
<ComboboxTrigger asChild>
<button
disabled={!perms.includes('feedback_issue_update')}
className={cn({
'opacity-50': !perms.includes('feedback_issue_update'),
})}
>
{!isPreview && (
<Tag
variant="outline"
className={cn('w-8 cursor-pointer justify-center', {
'hover:bg-inherit': !perms.includes('feedback_issue_update'),
})}
>
+
</Tag>
)}
</button>
</ComboboxTrigger>
<ComboboxContent
options={{ filter: commandFilter }}
className="w-[320px]"
>
<ComboboxInput
onClick={(e) => e.stopPropagation()}
onValueChange={(value) => setInputValue(value)}
value={inputValue}
/>
{!!feedbackId && !inputValue && (
<AiIssueComboboxGroup
projectId={projectId}
channelId={channelId}
feedbackId={feedbackId}
onSelect={async ({ name, option, id }) => {
if (option === 'CREATE') {
await onCreateIssue(name);
}
if (option === 'EXISTING' && id) {
onClickIssue(id);
}
}}
currentIssues={currentIssues}
/>
)}
<ComboboxList maxHeight="333px">
<ComboboxGroup
heading={
<span className="text-neutral-tertiary text-base-normal">
Issue List
</span>
}
>
<ComboboxEmpty>{t('v2.text.no-data.default')}</ComboboxEmpty>
{allIssues.map((issue) => (
<ComboboxItem
key={issue.id}
onSelect={() => onClickIssue(issue.id)}
value={issue.name}
className="group justify-between"
disabled={!perms.includes('feedback_issue_update')}
>
<IssueBadge
key={issue.id}
name={issue.name}
status={issue.status}
/>
{currentIssues.some((v) => v.id === issue.id) ?
<Icon name="RiCheckLine" size={16} />
: <span className="text-small-normal text-neutral-tertiary opacity-0 transition-opacity group-hover:opacity-100">
Add
</span>
}
</ComboboxItem>
))}
<InfiniteScrollArea
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
</ComboboxGroup>
</ComboboxList>
{!!inputValue &&
perms.includes('issue_create') &&
!currentIssues.some((issue) => issue.name === inputValue) &&
!allIssues.some((issue) => issue.name === inputValue) && (
<ComboboxItem
value={inputValue}
className="justify-between"
onSelect={() => onCreateIssue(inputValue)}
>
<IssueBadge name={inputValue} status="INIT" />
<span className="text-neutral-tertiary text-small-normal">
Create
</span>
</ComboboxItem>
)}
</ComboboxContent>
</Combobox>
{currentIssues.map((issue) => (
<IssueBadge key={issue.id} name={issue.name} status={issue.status} />
))}
</div>
);
};
export default IssueCell;
@@ -0,0 +1,136 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { createColumnHelper } from '@tanstack/react-table';
import dayjs from 'dayjs';
import { Badge, Icon, Tag } from '@ufb/react';
import { DATE_TIME_FORMAT, displayString, RowDragHandleCell } from '@/shared';
import { FIELD_PROPERTY_TEXT } from './field-utils';
import {
FIELD_FORMAT_ICON_MAP,
FIELD_STATUS_COLOR_MAP,
} from './field.constant';
import type { FieldInfo } from './field.type';
import { AiFieldOptionPopover, SelectOptionListPopover } from './ui';
const DEFAULT_FIELD_COLUMNS_KEYS = ['id', 'createdAt', 'updatedAt', 'issues'];
const columnHelper = createColumnHelper<FieldInfo>();
export const getFieldColumns = (reorder?: (data: FieldInfo[]) => void) =>
[
reorder ?
columnHelper.display({
id: 'drag-handle',
cell: ({ row }) => <RowDragHandleCell rowId={row.id} />,
size: 30,
enableSorting: false,
meta: { truncate: false },
})
: null,
columnHelper.accessor('key', {
header: 'Key',
cell: ({ getValue }) => (
<div className="flex items-center gap-1">
<span>{displayString(getValue())}</span>
{DEFAULT_FIELD_COLUMNS_KEYS.includes(getValue()) && (
<Badge variant="outline">Default</Badge>
)}
</div>
),
enableSorting: false,
}),
columnHelper.accessor('name', {
header: 'Display Name',
cell: ({ getValue }) => <span>{displayString(getValue())}</span>,
enableSorting: false,
}),
columnHelper.accessor('format', {
header: 'Format',
cell: ({ getValue }) => (
<div className="flex items-center gap-1">
<Icon name={FIELD_FORMAT_ICON_MAP[getValue()]} size={16} />
{getValue()}
</div>
),
enableSorting: false,
}),
columnHelper.display({
header: 'Option',
cell: ({ row }) => {
if (
(row.original.format === 'select' ||
row.original.format === 'multiSelect') &&
(row.original.options ?? []).length > 0
) {
return (
<SelectOptionListPopover options={row.original.options ?? []} />
);
}
if (row.original.format === 'aiField') {
return (
<AiFieldOptionPopover
aiFieldTemplateId={row.original.aiFieldTemplateId}
/>
);
}
return '-';
},
enableSorting: false,
meta: { truncate: false },
}),
columnHelper.accessor('property', {
header: 'Property',
cell: ({ getValue }) => (
<Tag variant="secondary" radius="large">
{FIELD_PROPERTY_TEXT[getValue()]}
</Tag>
),
enableSorting: false,
filterFn: (row, _, value: string[]) => {
return value.some((property) => property === row.getValue('property'));
},
meta: { truncate: false },
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ getValue }) => (
<Badge color={FIELD_STATUS_COLOR_MAP[getValue()]} radius="large">
{getValue()}
</Badge>
),
enableSorting: false,
filterFn: (row, _, value: string[]) => {
return value.some((status) => status === row.getValue('status'));
},
meta: { truncate: false },
}),
columnHelper.accessor('description', {
header: 'Description',
cell: ({ getValue }) => <span>{displayString(getValue())}</span>,
enableSorting: false,
}),
columnHelper.accessor('createdAt', {
header: 'Created',
cell: ({ getValue }) => (
<span>
{getValue() ? dayjs(getValue()).format(DATE_TIME_FORMAT) : '-'}
</span>
),
}),
].filter((v) => !!v);
@@ -0,0 +1,32 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { FieldInfo, FieldProperty } from './field.type';
export const isDefaultField = (field: FieldInfo) =>
field.key === 'id' ||
field.key === 'createdAt' ||
field.key === 'updatedAt' ||
field.key === 'issues';
export const sortField = (a: FieldInfo, b: FieldInfo) => {
return a.order - b.order;
};
export const FIELD_PROPERTY_TEXT: Record<FieldProperty, string> = {
READ_ONLY: 'Read Only',
EDITABLE: 'Editable',
};
@@ -0,0 +1,48 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { Color, IconNameType } from '@ufb/react';
import type { Field, FieldFormat } from './field.type';
export const FIELD_FORMAT_LIST = [
'text',
'keyword',
'number',
'date',
'select',
'multiSelect',
'images',
'aiField',
] as const;
export const FIELD_FORMAT_ICON_MAP: Record<FieldFormat, IconNameType> = {
text: 'RiText',
keyword: 'RiFontSize',
number: 'RiHashtag',
date: 'RiCalendarEventLine',
select: 'RiCheckboxCircleLine',
multiSelect: 'RiListCheck',
images: 'RiImageLine',
aiField: 'RiSparklingFill',
};
export const FIELD_STATUS_COLOR_MAP: Record<Field['status'], Color> = {
ACTIVE: 'green',
INACTIVE: 'red',
};
export const DEFAULT_FIELD_KEYS = ['id', 'createdAt', 'updatedAt', 'issues'];
+122
View File
@@ -0,0 +1,122 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { i18n } from 'next-i18next';
import { z } from 'zod';
import { FIELD_FORMAT_LIST } from './field.constant';
export const fieldOptionSchema = z.object({
id: z.number(),
key: z
.string()
.trim()
.min(1, { message: i18n?.t('hint.required') })
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
name: z
.string()
.trim()
.min(1, { message: i18n?.t('hint.required') })
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
});
export const fieldSchema = z.object({
id: z.number(),
key: z
.string()
.trim()
.min(1, { message: i18n?.t('hint.required') })
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) })
.regex(
/^[A-Za-z0-9_]*$/,
'String can only contain letters, numbers, and underscores, with no spaces or other special characters.',
),
name: z
.string()
.trim()
.min(1, { message: i18n?.t('hint.required') })
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
description: z
.string()
.trim()
.max(50, { message: i18n?.t('hint.max-length', { length: 50 }) })
.nullable(),
format: z.enum(FIELD_FORMAT_LIST),
property: z.enum(['READ_ONLY', 'EDITABLE']),
status: z.enum(['ACTIVE', 'INACTIVE']),
options: z.array(fieldOptionSchema).optional(),
createdAt: z.string(),
updatedAt: z.string(),
order: z.number(),
aiFieldTemplateId: z.number().nullable().optional(),
aiFieldTargetKeys: z.array(z.string()).nullable().optional(),
aiFieldAutoProcessing: z.boolean().nullable().optional(),
});
export const fieldInfoSchema = fieldSchema
.pick({
key: true,
name: true,
description: true,
format: true,
property: true,
status: true,
options: true,
order: true,
aiFieldTemplateId: true,
aiFieldTargetKeys: true,
aiFieldAutoProcessing: true,
})
.merge(
z.object({
id: z.number().optional(),
createdAt: z.string().optional(),
options: z
.array(
fieldOptionSchema
.pick({ key: true, name: true })
.merge(z.object({ id: z.number().optional() })),
)
.optional(),
}),
)
.refine(
(data) =>
data.format === 'select' || data.format === 'multiSelect' ?
!!data.options && data.options.length > 0
: true,
{
path: ['options'],
message: 'Option is required.',
},
)
.refine(
(data) => (data.format === 'aiField' ? !!data.aiFieldTemplateId : true),
{
path: ['aiFieldTemplateId'],
message: 'Template is required.',
},
)
.refine(
(data) =>
data.format === 'aiField' ?
!!data.aiFieldTargetKeys && data.aiFieldTargetKeys.length > 0
: true,
{
path: ['aiFieldTargetKeys'],
message: 'Target Fields are required.',
},
);
+34
View File
@@ -0,0 +1,34 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { z } from 'zod';
import type {
fieldInfoSchema,
fieldOptionSchema,
fieldSchema,
} from './field.schema';
export type Field = z.infer<typeof fieldSchema>;
export type FieldInfo = z.infer<typeof fieldInfoSchema>;
export type FieldFormat = Field['format'];
export type FieldProperty = Field['property'];
export type FieldStatus = Field['status'];
export type FieldOption = z.infer<typeof fieldOptionSchema>;
export type FieldOptionInfo = Omit<FieldOption, 'id'> & {
id?: number;
};
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export * from './field.type';
export * from './ui';
export * from './field-utils';
@@ -0,0 +1,63 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useRouter } from 'next/router';
import {
Caption,
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
Icon,
Tag,
} from '@ufb/react';
import { useOAIQuery } from '@/shared';
interface Props {
aiFieldTemplateId?: number | null;
}
const AiFieldOptionPopover = ({ aiFieldTemplateId }: Props) => {
const router = useRouter();
const projectId = +(router.query.projectId as string);
const { data } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/fieldTemplates',
variables: { projectId },
});
const aiFieldTemplate = data?.find((v) => v.id === aiFieldTemplateId);
if (!aiFieldTemplate) {
return <Caption variant="error">Template is not found</Caption>;
}
return (
<Dropdown>
<DropdownTrigger asChild data-state="close">
<Tag onClick={(e) => e.stopPropagation()}>
Template Option
<Icon name="RiInformation2Line" />
</Tag>
</DropdownTrigger>
<DropdownContent>
<DropdownItem>{aiFieldTemplate.title}</DropdownItem>
</DropdownContent>
</Dropdown>
);
};
export default AiFieldOptionPopover;
@@ -0,0 +1,133 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable no-useless-escape */
import { useMemo } from 'react';
import { useTranslation } from 'next-i18next';
import {
Button,
Icon,
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from '@ufb/react';
import { useOAIQuery } from '@/shared';
import { env } from '@/env';
interface IProps {
channelId: number;
projectId: number;
}
const FeedbackRequestCodePopover: React.FC<IProps> = (props) => {
const { channelId, projectId } = props;
const { t } = useTranslation();
const { data: channelData } = useOAIQuery({
path: '/api/admin/projects/{projectId}/channels/{channelId}',
variables: { channelId, projectId },
});
const { data: apiKeysData } = useOAIQuery({
path: '/api/admin/projects/{projectId}/api-keys',
variables: { projectId },
});
const apiKey = useMemo(
() => apiKeysData?.items.find((v) => !v.deletedAt) ?? null,
[apiKeysData],
);
const snippetBody = useMemo(() => {
if (!channelData?.fields) return null;
const body: Record<string, unknown> = {};
for (const field of channelData.fields) {
if (field.status === 'INACTIVE') continue;
if (field.key === 'id') continue;
if (field.key === 'updatedAt') continue;
const key = field.key === 'issues' ? `'issueNames'` : `'${field.key}'`;
switch (field.format) {
case 'number':
case 'date':
body[key] = field.format;
break;
case 'keyword':
case 'text':
body[key] = 'string';
break;
case 'select':
body[key] = field.options.map((v) => `'${v.name}'`).join(' or ');
break;
case 'multiSelect':
body[key] = `[${field.options.map((v) => `'${v.name}'`).join(', ')}]`;
break;
case 'images':
body[key] = `URL[]`;
break;
default:
break;
}
}
return body;
}, [channelData]);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">
<Icon name="RiCodeBlock" />
{t('main.setting.feedback-request-code')}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col gap-5 p-5">
<div className="flex items-center justify-between">
<h5 className="text-title-h5">
{t('main.setting.feedback-request-code')}
</h5>
<PopoverClose asChild>
<Button variant="ghost">
<Icon name="RiCloseLine" />
</Button>
</PopoverClose>
</div>
{!apiKey && (
<p className="text-tint-red">
No API KEY with Active status. Please create API KEY.
</p>
)}
<pre className="bg-neutral-tertiary text-small-normal whitespace-pre-wrap rounded p-4">
{`curl --request POST ${
env.NEXT_PUBLIC_API_BASE_URL
}/api/projects/${projectId}/channels/${channelId}/feedbacks \\\n--header 'Content-Type: application/json' \\\n--header 'x-api-key: ${
apiKey?.value ?? 'API_KEY'
}' \\\n--data-raw '${
snippetBody ?
JSON.stringify(snippetBody, null, 4)
.replace(/\"/g, '')
.replace(/\'/g, '"')
: ''
}'`}
</pre>
</PopoverContent>
</Popover>
);
};
export default FeedbackRequestCodePopover;
@@ -0,0 +1,35 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { Icon } from '@ufb/react';
import { FIELD_FORMAT_ICON_MAP } from '../field.constant';
import type { FieldFormat } from '../field.type';
interface Props {
name: string;
format: FieldFormat;
}
const FieldFormatLabel = ({ name, format }: Props) => {
return (
<div className="flex items-center gap-1">
<Icon name={FIELD_FORMAT_ICON_MAP[format]} size={16} />
{name}
</div>
);
};
export default FieldFormatLabel;
@@ -0,0 +1,486 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { zodResolver } from '@hookform/resolvers/zod';
import { useOverlay } from '@toss/use-overlay';
import { useTranslation } from 'next-i18next';
import { FormProvider, useForm } from 'react-hook-form';
import {
Button,
Caption,
Checkbox,
FormField,
Icon,
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
Switch,
Tag,
} from '@ufb/react';
import type { FormOverlayProps } from '@/shared';
import {
Card,
CardDescription,
CardHeader,
CardTitle,
DeleteDialog,
Path,
SelectInput,
TextInput,
useOAIQuery,
} from '@/shared';
import {
FormInput,
FormMultiSelect,
FormSelect,
} from '@/shared/ui/form-inputs';
import { FIELD_FORMAT_ICON_MAP, FIELD_FORMAT_LIST } from '../field.constant';
import { fieldInfoSchema } from '../field.schema';
import type { FieldInfo } from '../field.type';
const defaultValues: FieldInfo = {
description: '',
format: 'text',
name: '',
key: '',
status: 'ACTIVE',
property: 'READ_ONLY',
order: 0,
aiFieldTargetKeys: null,
aiFieldTemplateId: null,
aiFieldAutoProcessing: null,
};
interface IProps extends FormOverlayProps<FieldInfo> {
fieldRows: FieldInfo[];
}
const FieldSettingSheet: React.FC<IProps> = (props) => {
const {
data,
fieldRows,
close,
isOpen,
onSubmit: onSave,
onClickDelete,
disabledUpdate,
disabledDelete,
} = props;
const router = useRouter();
const projectId = parseInt(router.query.projectId as string, 10);
const { t } = useTranslation();
const [isSameKey, setIsSameKey] = useState(
data ? data.name === data.key : true,
);
const [optionInput, setOptionInput] = useState<string>('');
const overlay = useOverlay();
const isOriginalData = useMemo(() => (data ? !!data.id : false), [data]);
const isEditing = useMemo(() => !!data, [data]);
const isDefaultField = useMemo(
() =>
data?.key === 'id' ||
data?.key === 'issues' ||
data?.key === 'createdAt' ||
data?.key === 'updatedAt',
[data],
);
const otherFields = useMemo(
() => fieldRows.filter((v) => v.key !== data?.key),
[data, fieldRows],
);
const methods = useForm<FieldInfo>({
resolver: zodResolver(fieldInfoSchema),
defaultValues: data ?? defaultValues,
});
const {
watch,
setValue,
handleSubmit,
reset,
setError,
formState,
clearErrors,
control,
} = methods;
useEffect(() => {
if (isSameKey) setValue('name', watch('key'), { shouldDirty: true });
}, [isSameKey, watch('key')]);
const addOption = () => {
if (optionInput === '') return;
if (
watch('options')?.find(
(v) => v.name.toLowerCase() === optionInput.toLowerCase(),
)
) {
setError('options', { message: 'Option Name is duplicated' });
return;
}
setValue(
'options',
(watch('options') ?? []).concat({
name: optionInput,
key: optionInput,
}),
{ shouldDirty: true },
);
setOptionInput('');
clearErrors('options');
};
const removeOption = (targetIndex: number) => {
overlay.open(({ close, isOpen }) => (
<DeleteDialog
close={close}
isOpen={isOpen}
onClickDelete={() => {
setValue(
'options',
(watch('options') ?? []).filter((_, i) => i !== targetIndex),
{ shouldDirty: true },
);
}}
description={t('main.setting.dialog.delete-option.description')}
/>
));
};
const onSubmit = async (input: FieldInfo) => {
const checkDuplicatedKey = otherFields.find((v) => v.key === input.key);
if (checkDuplicatedKey) {
setError('key', { message: 'Key is duplicated' });
return;
}
const checkDuplicatedName = otherFields.find((v) => v.name === input.name);
if (checkDuplicatedName) {
setError('name', { message: 'Name is duplicated' });
return;
}
if (input.format === 'select' || input.format === 'multiSelect') {
if (!input.options || input.options.length === 0) {
setError('options', { message: 'Option is required' });
return;
}
}
await onSave({ ...data, ...input });
reset(defaultValues);
close();
};
const { data: aiIntegration } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/integrations',
variables: { projectId },
});
const { data: templates } = useOAIQuery({
path: '/api/admin/projects/{projectId}/ai/fieldTemplates',
variables: { projectId },
});
useEffect(() => {
if (!data) return;
if (data.format === 'aiField' && !data.aiFieldTemplateId) {
setError('aiFieldTemplateId', { message: 'Template is required' });
}
}, [data]);
return (
<Sheet open={isOpen} onOpenChange={close}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{isEditing ?
t('main.setting.dialog.edit-field.title')
: t('main.setting.dialog.add-field.title')}
</SheetTitle>
</SheetHeader>
<SheetBody asChild>
<FormProvider {...methods}>
<form
id="field-setting"
onSubmit={handleSubmit(onSubmit)}
className="flex w-[450px] flex-col gap-4"
>
<FormField
control={control}
name="key"
render={({ field }) => (
<FormInput
label="Key"
{...field}
disabled={isOriginalData || isDefaultField}
required={!data}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormInput
label="Display Name"
{...field}
disabled={isSameKey || isDefaultField}
required
/>
)}
/>
<Checkbox
checked={isSameKey}
onCheckedChange={(checked) => setIsSameKey(!!checked)}
disabled={isDefaultField}
>
{t('main.setting.same-key')}
</Checkbox>
</div>
<div>
<SelectInput
label="Format"
onChange={(value) => {
setValue('format', value as FieldInfo['format'], {
shouldDirty: true,
});
setValue('options', undefined);
setValue('aiFieldTemplateId', undefined);
setValue('aiFieldTargetKeys', undefined);
setValue('aiFieldAutoProcessing', undefined);
}}
options={FIELD_FORMAT_LIST.map((v) => ({
label: v,
value: v,
icon: FIELD_FORMAT_ICON_MAP[v],
disabled: v === 'aiField' && !aiIntegration?.apiKey,
}))}
value={watch('format')}
disabled={isOriginalData || isDefaultField}
required
/>
{watch('format') === 'images' && (
<Caption>{t('hint.image-format')}</Caption>
)}
</div>
{(watch('format') === 'select' ||
watch('format') === 'multiSelect') &&
!isDefaultField && (
<div>
<TextInput
label="Select Option"
value={optionInput}
maxLength={20}
onChange={(e) => setOptionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addOption();
}
}}
error={formState.errors.options?.message}
rightButton={
<Button onClick={addOption}>
{t('button.register')}
</Button>
}
required
/>
{(watch('options') ?? []).length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{watch('options')?.map((v, i) => (
<Tag key={i} radius="large" variant="secondary">
{v.name}
<Icon
name="RiCloseLargeLine"
onClick={() => removeOption(i)}
/>
</Tag>
))}
</div>
)}
</div>
)}
{watch('format') === 'aiField' && (
<>
<div>
<FormField
control={control}
name="aiFieldTemplateId"
render={({ field }) => (
<>
<FormSelect
label="Template"
options={
templates?.map(({ title, id }) => ({
label: title,
value: id.toString(),
})) ?? []
}
disabled={isDefaultField}
value={field.value?.toString()}
onChange={(value) => {
field.onChange(value ? parseInt(value) : null);
clearErrors('aiFieldTemplateId');
}}
required
/>
<Link
target="_blank"
href={{
pathname: Path.SETTINGS,
query: {
menu: 'generative-ai',
subMenu: 'field-template',
projectId,
},
}}
>
<Caption className="mt-1 flex items-center gap-0.5">
<Icon name="RiExternalLinkFill" size={16} />
Go to Template Settings
</Caption>
</Link>
</>
)}
/>
</div>
<FormField
control={control}
name="aiFieldTargetKeys"
render={({ field }) => (
<FormMultiSelect
label="Target Field"
options={fieldRows
.filter(({ key }) => key !== watch('key'))
.map(({ key, name }) => ({
label: name,
value: key,
}))}
{...field}
disabled={isDefaultField}
required
/>
)}
/>
<Card size="sm">
<CardHeader
action={
<Switch
checked={watch('aiFieldAutoProcessing') ?? false}
onCheckedChange={(checked) =>
setValue('aiFieldAutoProcessing', checked, {
shouldDirty: true,
})
}
/>
}
>
<CardTitle>AI Field Automation</CardTitle>
<CardDescription>
{t('v2.description.ai-field-template-auto-processing')}
</CardDescription>
</CardHeader>
</Card>
</>
)}
<FormField
control={control}
name="property"
render={({ field }) => (
<FormSelect
label="Property"
options={[
{ label: 'Read Only', value: 'READ_ONLY' },
{ label: 'Editable', value: 'EDITABLE' },
]}
{...field}
disabled={isDefaultField}
required
/>
)}
/>
<FormField
control={control}
name="status"
render={({ field }) => (
<FormSelect
label="Status"
options={[
{ label: 'Active', value: 'ACTIVE' },
{ label: 'Inactive', value: 'INACTIVE' },
]}
{...field}
disabled={isDefaultField}
required
/>
)}
/>
<FormField
control={control}
name="description"
render={({ field }) => (
<FormInput
label="Description"
{...field}
disabled={isDefaultField}
/>
)}
/>
</form>
</FormProvider>
</SheetBody>
<SheetFooter>
{data && (
<div className="flex-1">
<Button
disabled={isOriginalData || isDefaultField || disabledDelete}
variant="destructive"
onClick={() => onClickDelete?.()}
>
{t('v2.button.delete')}
</Button>
</div>
)}
<SheetClose>{t('v2.button.cancel')}</SheetClose>
<Button
type="submit"
form="field-setting"
disabled={isDefaultField || !formState.isDirty || disabledUpdate}
>
{t('v2.button.confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
};
export default FieldSettingSheet;
@@ -0,0 +1,95 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import {
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Button, Icon } from '@ufb/react';
import { BasicTable, TableFacetedFilter } from '@/shared';
import { getFieldColumns } from '../field-columns';
import type { FieldInfo } from '../field.type';
interface IProps {
fields: FieldInfo[];
onClickRow?: (index: number, field: FieldInfo) => void;
reorder?: (data: FieldInfo[]) => void;
disableFilter?: boolean;
}
const FieldTable: React.FC<IProps> = (props) => {
const { fields, onClickRow, reorder, disableFilter } = props;
const columns = useMemo(() => getFieldColumns(reorder), []);
const table = useReactTable({
columns,
data: fields.sort((a, b) => a.order - b.order),
enableColumnFilters: true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => String(row.key),
});
return (
<div className="flex h-full flex-col gap-4">
{!disableFilter && (
<div className="flex gap-3">
<TableFacetedFilter
column={table.getColumn('status')}
options={[
{ label: 'Inactive', value: 'INACTIVE' },
{ label: 'Active', value: 'ACTIVE' },
]}
title="Status"
/>
<TableFacetedFilter
column={table.getColumn('property')}
options={[
{ label: 'Editable', value: 'EDITABLE' },
{ label: 'Read Only', value: 'READ_ONLY' },
]}
title="Property"
/>
{table.getState().columnFilters.length > 0 && (
<Button variant="ghost" onClick={() => table.resetColumnFilters()}>
<Icon name="RiCloseLine" />
Reset
</Button>
)}
</div>
)}
<BasicTable
table={table}
onClickRow={(index, row) => onClickRow?.(index, row)}
reorder={(data) =>
reorder?.(
data.map((field, index) => ({ ...field, order: index + 1 })),
)
}
/>
</div>
);
};
export default FieldTable;
+22
View File
@@ -0,0 +1,22 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as FieldTable } from './field-table.ui';
export { default as FieldSettingSheet } from './field-setting-sheet.ui';
export { default as FeedbackRequestCodePopover } from './feedback-request-code-popover.ui';
export { default as PreviewFieldTable } from './preview-field-table.ui';
export { default as FieldFormatLabel } from './field-format-label.ui';
export { default as AiFieldOptionPopover } from './ai-field-option-popover.ui';
export { default as SelectOptionListPopover } from './select-option-list-popover.ui';
@@ -0,0 +1,53 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { memo, useMemo } from 'react';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { BasicTable } from '@/shared';
import { usePreviewFeedback } from '@/entities/feedback';
import { getColumns } from '@/entities/feedback/feedback-table-columns';
import type { FieldInfo } from '../field.type';
interface IProps {
fields: FieldInfo[];
}
const PreviewFieldTable: React.FC<IProps> = ({ fields }) => {
const columns = useMemo(
() => getColumns(fields, true).filter((v) => v.id !== 'feedback-checkbox'),
[fields],
);
const feedbacks = usePreviewFeedback(fields);
const table = useReactTable({
columns,
data: feedbacks,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => String(row.id),
});
return (
<div className="h-full overflow-auto">
<BasicTable table={table} className="table-fixed" />
</div>
);
};
export default memo(
PreviewFieldTable,
(prev, next) => JSON.stringify(prev.fields) === JSON.stringify(next.fields),
);
@@ -0,0 +1,50 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import {
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
Icon,
Tag,
} from '@ufb/react';
import type { FieldOptionInfo } from '../field.type';
interface IProps {
options: FieldOptionInfo[];
}
const SelectOptionListPopover: React.FC<IProps> = ({ options }) => {
return (
<Dropdown>
<DropdownTrigger asChild data-state="close">
<Tag onClick={(e) => e.stopPropagation()}>
Select Options
<Icon name="RiInformation2Line" />
</Tag>
</DropdownTrigger>
<DropdownContent>
{options.map((v) => (
<DropdownItem key={v.key}>{v.name}</DropdownItem>
))}
</DropdownContent>
</Dropdown>
);
};
export default SelectOptionListPopover;
@@ -0,0 +1,18 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export * from './ui';
export * from './issue-tracker.type';
export * from './issue-tracker.schema';
@@ -0,0 +1,21 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { z } from 'zod';
export const issueTrackerSchema = z.object({
ticketDomain: z.string().trim().url().nullable().or(z.literal('')),
ticketKey: z.string().trim().nullable(),
});
@@ -0,0 +1,20 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { z } from 'zod';
import type { issueTrackerSchema } from './issue-tracker.schema';
export type IssueTracker = z.infer<typeof issueTrackerSchema>;
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as IssueTrackerForm } from './issue-tracker-form.ui';
@@ -0,0 +1,70 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useFormContext } from 'react-hook-form';
import { Icon, InputField, Label } from '@ufb/react';
import { cn, SelectInput, TextInput } from '@/shared';
import type { IssueTracker } from '../issue-tracker.type';
interface IProps {
readOnly?: boolean;
}
const IssueTrackerForm: React.FC<IProps> = ({ readOnly }) => {
const { register, watch, formState } = useFormContext<IssueTracker>();
return (
<div className="flex flex-col gap-4">
<SelectInput
options={[{ value: 'jira', label: 'Jira' }]}
value="jira"
label="Issue Tracking System"
disabled={readOnly}
/>
<TextInput
label="Base URL"
placeholder="https://example.com"
error={formState.errors.ticketDomain?.message}
{...register('ticketDomain')}
/>
<TextInput
label="Project Key"
placeholder="PROJ"
error={formState.errors.ticketKey?.message}
disabled={readOnly}
{...register('ticketKey')}
/>
<InputField>
<Label>Preview</Label>
<div
className={cn(
'bg-neutral-tertiary flex items-center gap-2 rounded p-4',
{ 'opacity-50': !watch('ticketDomain') || !watch('ticketKey') },
)}
>
<Icon name="RiPriceTag3Fill" size={16} />
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{`${watch('ticketDomain') || 'https://example.com'}/browse/${watch('ticketKey') || 'PROJ'}-{Number}`}
</div>
</InputField>
</div>
);
};
export default IssueTrackerForm;
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export * from './issue.type';
export * from './ui';
export * from './lib';
@@ -0,0 +1,44 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { z } from 'zod';
import { categorySchema } from '../category/category.schema';
export const issueSchema = z.object({
id: z.number(),
name: z.string().trim().min(1).max(30),
description: z.string().trim().max(50).nullable(),
feedbackCount: z.number(),
status: z.union([
z.literal('INIT'),
z.literal('ON_REVIEW'),
z.literal('IN_PROGRESS'),
z.literal('RESOLVED'),
z.literal('PENDING'),
]),
externalIssueId: z.string().trim().optional(),
createdAt: z.string(),
updatedAt: z.string().nullable(),
category: categorySchema.nullable(),
});
export const issueFormSchema = issueSchema.pick({
name: true,
description: true,
status: true,
externalIssueId: true,
});
+22
View File
@@ -0,0 +1,22 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { z } from 'zod';
import type { issueFormSchema, issueSchema } from './issue.schema';
export type Issue = z.infer<typeof issueSchema>;
export type IssueStatus = Issue['status'];
export type IssueFormSchema = z.infer<typeof issueFormSchema>;
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as useIssueSearch } from './use-issue-search';
export { default as useIssueSearchInfinite } from './use-issue-search-infinite';
@@ -0,0 +1,82 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useInfiniteQuery } from '@tanstack/react-query';
import type {
OAIMutationResponse,
OAIRequestBody,
SearchQuery,
} from '@/shared';
import { client } from '@/shared';
type TData = OAIMutationResponse<
'/api/admin/projects/{projectId}/issues/search',
'post'
>;
interface IBody
extends Omit<
OAIRequestBody<'/api/admin/projects/{projectId}/issues/search', 'post'>,
'queries'
> {
queries: SearchQuery[];
}
const useIssueSearchInfinite = (
projectId: number,
body: IBody = { limit: 20, page: 1, queries: [], sort: {} },
) => {
return useInfiniteQuery<TData>({
queryKey: [
'/api/admin/projects/{projectId}/issues/search',
projectId,
body,
],
queryFn: async ({ pageParam }) => {
const { data: result } = await client.post({
path: '/api/admin/projects/{projectId}/issues/search',
pathParams: { projectId },
body: { ...body, page: pageParam as number },
});
return result;
},
getNextPageParam: (lastPage) => {
if (!lastPage) return undefined;
if (lastPage.meta.currentPage < lastPage.meta.totalPages) {
return lastPage.meta.currentPage + 1;
}
return undefined;
},
initialPageParam: 1,
initialData: {
pageParams: [],
pages: [
{
items: [],
meta: {
currentPage: 1,
totalPages: 0,
totalItems: 0,
itemCount: 0,
itemsPerPage: 0,
},
},
],
},
});
};
export default useIssueSearchInfinite;
@@ -0,0 +1,62 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type {
OAIMutationResponse,
OAIRequestBody,
SearchQuery,
} from '@/shared';
import { client } from '@/shared';
type TData = OAIMutationResponse<
'/api/admin/projects/{projectId}/issues/search',
'post'
>;
interface IBody
extends Omit<
OAIRequestBody<'/api/admin/projects/{projectId}/issues/search', 'post'>,
'queries'
> {
queries: SearchQuery[];
}
const useIssueSearch = (
projectId: number,
body: IBody = { limit: 10, page: 1, queries: [], sort: {} },
options?: Omit<UseQueryOptions<TData>, 'queryKey' | 'queryFn'>,
) => {
return useQuery<TData>({
queryKey: [
'/api/admin/projects/{projectId}/issues/search',
projectId,
body,
],
queryFn: async () => {
const { data: result } = await client.post({
path: '/api/admin/projects/{projectId}/issues/search',
pathParams: { projectId },
body,
});
return result;
},
...options,
});
};
export default useIssueSearch;
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as IssueBadge } from './issue-badge.ui';
export { default as IssueSelectBox } from './issue-select-box.ui';
@@ -0,0 +1,50 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useTranslation } from 'next-i18next';
import type { BadgeProps } from '@ufb/react';
import { Badge } from '@ufb/react';
import { ISSUES } from '@/shared';
import { BADGE_COLOR_MAP } from '@/shared/constants/color-map';
import type { BadgeColor } from '@/shared/constants/color-map';
import type { Issue, IssueStatus } from '../issue.type';
const ISSUE_COLOR_MAP: Record<IssueStatus, BadgeColor> = {
INIT: 'yellow',
ON_REVIEW: 'green',
IN_PROGRESS: 'sky',
RESOLVED: 'zinc',
PENDING: 'indigo',
};
interface IProps extends Omit<BadgeProps, 'color'> {
right?: React.ReactNode;
status: Issue['status'];
name?: string;
}
const IssueBadge: React.FC<IProps> = ({ name, status, right, ...props }) => {
const { t } = useTranslation();
return (
<Badge {...props} className={BADGE_COLOR_MAP[ISSUE_COLOR_MAP[status]]}>
{name ?? ISSUES(t).find((v) => v.key === status)?.name} {right}
</Badge>
);
};
export default IssueBadge;
@@ -0,0 +1,91 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { useThrottle } from 'react-use';
import { client } from '@/shared';
import AsyncMultiSelectSearchInput from '@/shared/ui/inputs/async-multi-select-search-input.ui';
import { useIssueSearchInfinite } from '../lib';
interface Props {
onChange: (value?: number[]) => void;
value?: number[];
}
const IssueSelectBox = ({ onChange, value }: Props) => {
const router = useRouter();
const projectId = Number(router.query.projectId as string);
const [selectedIssues, setSelectedIssues] = useState<
{ label: string; value: string }[]
>([]);
const [inputValue, setInputValue] = useState('');
const throttedValue = useThrottle(inputValue, 500);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useIssueSearchInfinite(projectId, {
queries: [{ key: 'name', value: throttedValue, condition: 'CONTAINS' }],
sort: { name: 'ASC' },
});
useEffect(() => {
if (!value) {
setSelectedIssues([]);
return;
}
void Promise.allSettled(
value.map(async (issueId) => {
try {
const { data } = await client.get({
path: '/api/admin/projects/{projectId}/issues/{issueId}',
pathParams: { projectId, issueId },
});
return { label: data.name, value: String(issueId) };
} catch (error) {
console.warn(`Failed to fetch issue ${issueId}:`, error);
return { label: `Issue ${issueId}`, value: String(issueId) };
}
}),
).then((results) => {
const successfulResults = results
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value);
setSelectedIssues(successfulResults);
});
}, [value]);
return (
<AsyncMultiSelectSearchInput
options={data.pages.flatMap((page) =>
page ?
page.items.map(({ name, id }) => ({ label: name, value: String(id) }))
: [],
)}
value={selectedIssues}
onChange={(input) => onChange(input.map((v) => Number(v.value)))}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
inputValue={inputValue}
setInputValue={setInputValue}
isFetchingNextPage={isFetchingNextPage}
/>
);
};
export default IssueSelectBox;
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export * from './ui';
export * from './member.type';
export * from './member.schema';
Loaded 100 of 4633 files, more files were not shown because too many files have changed in this diff. Show more